@agentunion/fastaun-browser 0.2.13
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/README.md +604 -0
- package/dist/auth.d.ts +150 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +1388 -0
- package/dist/auth.js.map +1 -0
- package/dist/certs/root.d.ts +2 -0
- package/dist/certs/root.d.ts.map +1 -0
- package/dist/certs/root.js +16 -0
- package/dist/certs/root.js.map +1 -0
- package/dist/client.d.ts +341 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +4061 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +85 -0
- package/dist/config.js.map +1 -0
- package/dist/crypto.d.ts +41 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +132 -0
- package/dist/crypto.js.map +1 -0
- package/dist/discovery.d.ts +20 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +75 -0
- package/dist/discovery.js.map +1 -0
- package/dist/e2ee-group.d.ts +221 -0
- package/dist/e2ee-group.d.ts.map +1 -0
- package/dist/e2ee-group.js +1174 -0
- package/dist/e2ee-group.js.map +1 -0
- package/dist/e2ee.d.ts +187 -0
- package/dist/e2ee.d.ts.map +1 -0
- package/dist/e2ee.js +1067 -0
- package/dist/e2ee.js.map +1 -0
- package/dist/errors.d.ts +118 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +250 -0
- package/dist/errors.js.map +1 -0
- package/dist/events.d.ts +33 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +68 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/keystore/index.d.ts +88 -0
- package/dist/keystore/index.d.ts.map +1 -0
- package/dist/keystore/index.js +3 -0
- package/dist/keystore/index.js.map +1 -0
- package/dist/keystore/indexeddb.d.ts +94 -0
- package/dist/keystore/indexeddb.d.ts.map +1 -0
- package/dist/keystore/indexeddb.js +1434 -0
- package/dist/keystore/indexeddb.js.map +1 -0
- package/dist/namespaces/auth.d.ts +52 -0
- package/dist/namespaces/auth.d.ts.map +1 -0
- package/dist/namespaces/auth.js +237 -0
- package/dist/namespaces/auth.js.map +1 -0
- package/dist/namespaces/custody.d.ts +48 -0
- package/dist/namespaces/custody.d.ts.map +1 -0
- package/dist/namespaces/custody.js +230 -0
- package/dist/namespaces/custody.js.map +1 -0
- package/dist/secret-store/index.d.ts +20 -0
- package/dist/secret-store/index.d.ts.map +1 -0
- package/dist/secret-store/index.js +12 -0
- package/dist/secret-store/index.js.map +1 -0
- package/dist/secret-store/indexeddb-store.d.ts +22 -0
- package/dist/secret-store/indexeddb-store.d.ts.map +1 -0
- package/dist/secret-store/indexeddb-store.js +133 -0
- package/dist/secret-store/indexeddb-store.js.map +1 -0
- package/dist/seq-tracker.d.ts +30 -0
- package/dist/seq-tracker.d.ts.map +1 -0
- package/dist/seq-tracker.js +219 -0
- package/dist/seq-tracker.js.map +1 -0
- package/dist/transport.d.ts +45 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +251 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +171 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -0
- package/package.json +37 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,4061 @@
|
|
|
1
|
+
// ── AUNClient(SDK 主入口 — 浏览器完整实现)──────────────────
|
|
2
|
+
// 对标 Python client.py,浏览器环境适配:
|
|
3
|
+
// - 所有密码学操作异步(SubtleCrypto)
|
|
4
|
+
// - HTTP 使用 fetch() 而非 Node http
|
|
5
|
+
// - 无文件系统(IndexedDB via keystore)
|
|
6
|
+
// - 后台任务使用 setTimeout/setInterval
|
|
7
|
+
import { createConfig, getDeviceId, normalizeInstanceId } from './config.js';
|
|
8
|
+
import { EventDispatcher } from './events.js';
|
|
9
|
+
import { GatewayDiscovery } from './discovery.js';
|
|
10
|
+
import { RPCTransport } from './transport.js';
|
|
11
|
+
import { AuthFlow } from './auth.js';
|
|
12
|
+
import { SeqTracker } from './seq-tracker.js';
|
|
13
|
+
import { AuthNamespace } from './namespaces/auth.js';
|
|
14
|
+
import { CustodyNamespace } from './namespaces/custody.js';
|
|
15
|
+
import { CryptoProvider, uint8ToBase64, base64ToUint8, pemToArrayBuffer, p1363ToDer, toBufferSource } from './crypto.js';
|
|
16
|
+
import { E2EEManager, _certificateSha256Fingerprint as certificateSha256Fingerprint, _ecdsaVerifyDer as ecdsaVerifyDer, _importCertPublicKeyEcdsa as importCertPublicKeyEcdsa, } from './e2ee.js';
|
|
17
|
+
import { GroupE2EEManager, computeMembershipCommitment, storeGroupSecret, buildKeyDistribution, buildKeyRequest, buildMembershipManifest, signMembershipManifest, } from './e2ee-group.js';
|
|
18
|
+
import { IndexedDBKeyStore } from './keystore/indexeddb.js';
|
|
19
|
+
import { AUNError, AuthError, ConnectionError, E2EEError, PermissionError, StateError, ValidationError, } from './errors.js';
|
|
20
|
+
import { isJsonObject, } from './types.js';
|
|
21
|
+
/**
|
|
22
|
+
* 递归排序键的 JSON 序列化(Canonical JSON for AUN)
|
|
23
|
+
* 等价于 Python json.dumps(sort_keys=True, separators=(",",":"), ensure_ascii=False)
|
|
24
|
+
* 非 ASCII 字符直接以 UTF-8 输出。
|
|
25
|
+
*/
|
|
26
|
+
function stableStringify(obj) {
|
|
27
|
+
if (obj === null || obj === undefined)
|
|
28
|
+
return 'null';
|
|
29
|
+
if (typeof obj === 'boolean' || typeof obj === 'number')
|
|
30
|
+
return JSON.stringify(obj);
|
|
31
|
+
if (typeof obj === 'string')
|
|
32
|
+
return JSON.stringify(obj);
|
|
33
|
+
if (Array.isArray(obj)) {
|
|
34
|
+
return '[' + obj.map(v => stableStringify(v)).join(',') + ']';
|
|
35
|
+
}
|
|
36
|
+
if (isJsonObject(obj)) {
|
|
37
|
+
const keys = Object.keys(obj).sort();
|
|
38
|
+
const entries = keys.map(k => stableStringify(k) + ':' + stableStringify(obj[k]));
|
|
39
|
+
return '{' + entries.join(',') + '}';
|
|
40
|
+
}
|
|
41
|
+
return JSON.stringify(obj);
|
|
42
|
+
}
|
|
43
|
+
/** 内部专用方法(禁止用户直接调用) */
|
|
44
|
+
const INTERNAL_ONLY_METHODS = new Set([
|
|
45
|
+
'auth.login1',
|
|
46
|
+
'auth.aid_login1',
|
|
47
|
+
'auth.login2',
|
|
48
|
+
'auth.aid_login2',
|
|
49
|
+
'auth.connect',
|
|
50
|
+
'auth.refresh_token',
|
|
51
|
+
'initialize',
|
|
52
|
+
]);
|
|
53
|
+
/** 需要客户端签名的关键方法 */
|
|
54
|
+
const SIGNED_METHODS = new Set([
|
|
55
|
+
'group.send', 'group.kick', 'group.add_member',
|
|
56
|
+
'group.leave', 'group.remove_member', 'group.update_rules',
|
|
57
|
+
'group.update', 'group.update_announcement',
|
|
58
|
+
'group.update_join_requirements', 'group.set_role',
|
|
59
|
+
'group.transfer_owner', 'group.review_join_request',
|
|
60
|
+
'group.batch_review_join_request',
|
|
61
|
+
'group.request_join', 'group.use_invite_code',
|
|
62
|
+
'group.resources.put', 'group.resources.update',
|
|
63
|
+
'group.resources.delete', 'group.resources.request_add',
|
|
64
|
+
'group.resources.direct_add', 'group.resources.approve_request',
|
|
65
|
+
'group.resources.reject_request',
|
|
66
|
+
]);
|
|
67
|
+
const DEFAULT_SESSION_OPTIONS = {
|
|
68
|
+
auto_reconnect: true,
|
|
69
|
+
heartbeat_interval: 30.0,
|
|
70
|
+
token_refresh_before: 60.0,
|
|
71
|
+
retry: {
|
|
72
|
+
initial_delay: 1.0,
|
|
73
|
+
max_delay: 64.0,
|
|
74
|
+
// M25: 0 表示无限重试,与 Go/Python 对齐
|
|
75
|
+
max_attempts: 0,
|
|
76
|
+
},
|
|
77
|
+
timeouts: {
|
|
78
|
+
connect: 5.0,
|
|
79
|
+
call: 10.0,
|
|
80
|
+
http: 30.0,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
const RECONNECT_MIN_BASE_DELAY_SECONDS = 1.0;
|
|
84
|
+
const RECONNECT_MAX_BASE_DELAY_SECONDS = 64.0;
|
|
85
|
+
const GROUP_ROTATION_LEASE_MS = 120_000;
|
|
86
|
+
const GROUP_ROTATION_RETRY_MAX_DELAY_MS = 300_000;
|
|
87
|
+
function clampReconnectDelaySeconds(value, fallback, upper = RECONNECT_MAX_BASE_DELAY_SECONDS) {
|
|
88
|
+
const parsed = Number(value);
|
|
89
|
+
const seconds = Number.isFinite(parsed) ? parsed : fallback;
|
|
90
|
+
return Math.min(Math.max(seconds, RECONNECT_MIN_BASE_DELAY_SECONDS), upper);
|
|
91
|
+
}
|
|
92
|
+
function reconnectSleepDelaySeconds(baseDelay, maxBaseDelay) {
|
|
93
|
+
return baseDelay + Math.random() * maxBaseDelay;
|
|
94
|
+
}
|
|
95
|
+
/** 对端证书缓存 TTL(秒) */
|
|
96
|
+
const PEER_CERT_CACHE_TTL = 600;
|
|
97
|
+
/**
|
|
98
|
+
* 将 WebSocket URL 转为对应的 HTTP URL
|
|
99
|
+
*/
|
|
100
|
+
function gatewayHttpUrl(gatewayUrl, path) {
|
|
101
|
+
try {
|
|
102
|
+
const parsed = new URL(gatewayUrl);
|
|
103
|
+
const scheme = parsed.protocol === 'wss:' ? 'https:' : 'http:';
|
|
104
|
+
return `${scheme}//${parsed.host}${path}`;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
const httpUrl = gatewayUrl
|
|
108
|
+
.replace(/^wss:/, 'https:')
|
|
109
|
+
.replace(/^ws:/, 'http:');
|
|
110
|
+
const urlObj = new URL(httpUrl);
|
|
111
|
+
urlObj.pathname = path;
|
|
112
|
+
urlObj.search = '';
|
|
113
|
+
urlObj.hash = '';
|
|
114
|
+
return urlObj.toString();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function certCacheKey(aid, certFingerprint) {
|
|
118
|
+
const normalized = String(certFingerprint ?? '').trim().toLowerCase();
|
|
119
|
+
return normalized ? `${aid}#${normalized}` : aid;
|
|
120
|
+
}
|
|
121
|
+
function buildCertUrl(gatewayUrl, aid, certFingerprint) {
|
|
122
|
+
const url = new URL(gatewayHttpUrl(gatewayUrl, `/pki/cert/${encodeURIComponent(aid)}`));
|
|
123
|
+
const normalized = String(certFingerprint ?? '').trim().toLowerCase();
|
|
124
|
+
if (normalized) {
|
|
125
|
+
url.searchParams.set('cert_fingerprint', normalized);
|
|
126
|
+
}
|
|
127
|
+
return url.toString();
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 跨域时将 Gateway URL 替换为 peer 所在域的 Gateway URL。
|
|
131
|
+
*
|
|
132
|
+
* 例: local=wss://gateway.aid.com:20001/aun, peer=bob.aid.net
|
|
133
|
+
* → wss://gateway.aid.net:20001/aun
|
|
134
|
+
*/
|
|
135
|
+
function resolvePeerGatewayUrl(localGatewayUrl, peerAid) {
|
|
136
|
+
if (!peerAid.includes('.'))
|
|
137
|
+
return localGatewayUrl;
|
|
138
|
+
const peerIssuer = peerAid.split('.').slice(1).join('.');
|
|
139
|
+
const match = localGatewayUrl.match(/gateway\.([^:/]+)/);
|
|
140
|
+
if (!match)
|
|
141
|
+
return localGatewayUrl;
|
|
142
|
+
const localIssuer = match[1];
|
|
143
|
+
if (localIssuer === peerIssuer)
|
|
144
|
+
return localGatewayUrl;
|
|
145
|
+
return localGatewayUrl.replace(`gateway.${localIssuer}`, `gateway.${peerIssuer}`);
|
|
146
|
+
}
|
|
147
|
+
function isGroupServiceAid(value) {
|
|
148
|
+
const text = String(value ?? '').trim();
|
|
149
|
+
if (!text.includes('.'))
|
|
150
|
+
return false;
|
|
151
|
+
const [name, ...issuerParts] = text.split('.');
|
|
152
|
+
return name === 'group' && issuerParts.join('.').length > 0;
|
|
153
|
+
}
|
|
154
|
+
function isPeerPrekeyMaterial(value) {
|
|
155
|
+
if (!isJsonObject(value))
|
|
156
|
+
return false;
|
|
157
|
+
const candidate = value;
|
|
158
|
+
return (typeof candidate.prekey_id === 'string'
|
|
159
|
+
&& typeof candidate.public_key === 'string'
|
|
160
|
+
&& typeof candidate.signature === 'string'
|
|
161
|
+
&& (candidate.created_at === undefined || typeof candidate.created_at === 'number'));
|
|
162
|
+
}
|
|
163
|
+
function isPeerPrekeyResponse(value) {
|
|
164
|
+
if (!isJsonObject(value))
|
|
165
|
+
return false;
|
|
166
|
+
const candidate = value;
|
|
167
|
+
if (typeof candidate.found !== 'boolean')
|
|
168
|
+
return false;
|
|
169
|
+
return candidate.prekey === undefined || isPeerPrekeyMaterial(candidate.prekey);
|
|
170
|
+
}
|
|
171
|
+
function formatCaughtError(error) {
|
|
172
|
+
return error instanceof Error ? error : String(error);
|
|
173
|
+
}
|
|
174
|
+
function normalizeDeliveryModeConfig(raw, opts = {}) {
|
|
175
|
+
const defaultMode = String(opts.defaultMode ?? 'fanout').trim().toLowerCase() || 'fanout';
|
|
176
|
+
const defaultRouting = String(opts.defaultRouting ?? 'round_robin').trim().toLowerCase() || 'round_robin';
|
|
177
|
+
const defaultAffinityTtlMs = Number(opts.defaultAffinityTtlMs ?? 0);
|
|
178
|
+
let candidate;
|
|
179
|
+
if (typeof raw === 'string') {
|
|
180
|
+
candidate = { mode: raw };
|
|
181
|
+
}
|
|
182
|
+
else if (isJsonObject(raw)) {
|
|
183
|
+
candidate = { ...raw };
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
candidate = {};
|
|
187
|
+
}
|
|
188
|
+
const mode = String(candidate.mode ?? defaultMode).trim().toLowerCase() || 'fanout';
|
|
189
|
+
if (mode !== 'fanout' && mode !== 'queue') {
|
|
190
|
+
throw new ValidationError("delivery_mode must be 'fanout' or 'queue'");
|
|
191
|
+
}
|
|
192
|
+
let routing = String(candidate.routing ?? (mode === 'queue' ? defaultRouting : '')).trim().toLowerCase();
|
|
193
|
+
if (mode !== 'queue') {
|
|
194
|
+
routing = '';
|
|
195
|
+
}
|
|
196
|
+
else if (routing && routing !== 'round_robin' && routing !== 'sender_affinity') {
|
|
197
|
+
throw new ValidationError("queue_routing must be 'round_robin' or 'sender_affinity'");
|
|
198
|
+
}
|
|
199
|
+
else if (!routing) {
|
|
200
|
+
routing = 'round_robin';
|
|
201
|
+
}
|
|
202
|
+
const ttlRaw = candidate.affinity_ttl_ms ?? (mode === 'queue' ? defaultAffinityTtlMs : 0);
|
|
203
|
+
const affinityTtlMs = Math.max(0, Number(ttlRaw ?? 0));
|
|
204
|
+
if (!Number.isFinite(affinityTtlMs)) {
|
|
205
|
+
throw new ValidationError('affinity_ttl_ms must be an integer');
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
mode,
|
|
209
|
+
routing,
|
|
210
|
+
affinity_ttl_ms: affinityTtlMs,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* AUN Core SDK 客户端 — 浏览器版本。
|
|
215
|
+
*
|
|
216
|
+
* 职责:
|
|
217
|
+
* - 连接管理(WebSocket + 自动重连 + 指数退避)
|
|
218
|
+
* - 认证(token 初始化 / 多策略认证 / token 刷新)
|
|
219
|
+
* - RPC 调用(JSON-RPC 2.0)
|
|
220
|
+
* - E2EE 自动编排(加密/解密/密钥管理/group 生命周期)
|
|
221
|
+
* - 事件分发与管道
|
|
222
|
+
* - 后台任务(心跳、token 刷新、prekey 轮换、epoch 清理/轮换)
|
|
223
|
+
*
|
|
224
|
+
*/
|
|
225
|
+
export class AUNClient {
|
|
226
|
+
/** SDK 配置模型 */
|
|
227
|
+
configModel;
|
|
228
|
+
/** 原始配置字典 */
|
|
229
|
+
config;
|
|
230
|
+
_aid = null;
|
|
231
|
+
_identity = null;
|
|
232
|
+
_state = 'idle';
|
|
233
|
+
_gatewayUrl = null;
|
|
234
|
+
_deviceId;
|
|
235
|
+
_slotId;
|
|
236
|
+
_connectDeliveryMode;
|
|
237
|
+
_defaultConnectDeliveryMode;
|
|
238
|
+
_closing = false;
|
|
239
|
+
_sessionParams = null;
|
|
240
|
+
_sessionOptions = { ...DEFAULT_SESSION_OPTIONS };
|
|
241
|
+
_dispatcher;
|
|
242
|
+
_discovery;
|
|
243
|
+
_keystore;
|
|
244
|
+
_auth;
|
|
245
|
+
_transport;
|
|
246
|
+
_e2ee;
|
|
247
|
+
_groupE2ee;
|
|
248
|
+
/** 认证命名空间 */
|
|
249
|
+
auth;
|
|
250
|
+
/** AID 托管命名空间 */
|
|
251
|
+
custody;
|
|
252
|
+
// E2EE 编排状态(内存缓存)
|
|
253
|
+
_certCache = new Map();
|
|
254
|
+
_prekeyReplenishInflight = new Set();
|
|
255
|
+
_prekeyReplenished = new Set();
|
|
256
|
+
_peerPrekeysCache = new Map();
|
|
257
|
+
// 后台任务 handle(浏览器 setInterval/setTimeout)
|
|
258
|
+
_heartbeatTimer = null;
|
|
259
|
+
_tokenRefreshTimer = null;
|
|
260
|
+
/** 非连接状态下 token 刷新的退避计数器 */
|
|
261
|
+
_tokenDisconnectedRetries = 0;
|
|
262
|
+
_tokenRefreshFailures = 0;
|
|
263
|
+
_prekeyRefreshTimer = null;
|
|
264
|
+
_groupEpochCleanupTimer = null;
|
|
265
|
+
_groupEpochRotateTimer = null;
|
|
266
|
+
_cacheCleanupTimer = null;
|
|
267
|
+
/** 消息序列号跟踪器(群消息 + P2P 空洞检测) */
|
|
268
|
+
_seqTracker = new SeqTracker();
|
|
269
|
+
_seqTrackerContext = null;
|
|
270
|
+
/** 补洞去重:已完成/进行中的 key 集合,防止重复 pull 同一区间 */
|
|
271
|
+
_gapFillDone = new Set();
|
|
272
|
+
/** 推送路径已分发的 seq 集合(按命名空间),补洞路径 publish 前检查以避免重复分发 */
|
|
273
|
+
_pushedSeqs = new Map();
|
|
274
|
+
_pendingDecryptMsgs = new Map();
|
|
275
|
+
_groupEpochRotationInflight = new Set();
|
|
276
|
+
_groupEpochRecoveryInflight = new Map();
|
|
277
|
+
_groupMembershipRotationDone = new Set();
|
|
278
|
+
_groupEpochRotationRetryTimers = new Map();
|
|
279
|
+
/** Lazy group sync:首次发送群消息前自动拉取历史 */
|
|
280
|
+
_groupSynced = new Set();
|
|
281
|
+
/** Lazy P2P sync:首次发送 P2P 消息前自动拉取历史 */
|
|
282
|
+
_p2pSynced = false;
|
|
283
|
+
/** gap fill 来源标记:true 表示当前正在补洞(pull 触发),false 表示非补洞 */
|
|
284
|
+
_gapFillActive = false;
|
|
285
|
+
// 重连相关
|
|
286
|
+
_reconnectActive = false;
|
|
287
|
+
_reconnectAbort = null;
|
|
288
|
+
_serverKicked = false;
|
|
289
|
+
constructor(config, _debug = false) {
|
|
290
|
+
const rawConfig = config ?? {};
|
|
291
|
+
this.configModel = createConfig(rawConfig);
|
|
292
|
+
this.config = {
|
|
293
|
+
aun_path: this.configModel.aunPath,
|
|
294
|
+
root_ca_path: this.configModel.rootCaPem,
|
|
295
|
+
seed_password: this.configModel.seedPassword,
|
|
296
|
+
};
|
|
297
|
+
this._dispatcher = new EventDispatcher();
|
|
298
|
+
this._discovery = new GatewayDiscovery();
|
|
299
|
+
this._keystore = new IndexedDBKeyStore();
|
|
300
|
+
this._deviceId = getDeviceId();
|
|
301
|
+
this._slotId = '';
|
|
302
|
+
this._connectDeliveryMode = normalizeDeliveryModeConfig({ mode: 'fanout' });
|
|
303
|
+
this._defaultConnectDeliveryMode = { ...this._connectDeliveryMode };
|
|
304
|
+
this._auth = new AuthFlow({
|
|
305
|
+
keystore: this._keystore,
|
|
306
|
+
crypto: new CryptoProvider(),
|
|
307
|
+
aid: null,
|
|
308
|
+
deviceId: this._deviceId,
|
|
309
|
+
slotId: this._slotId,
|
|
310
|
+
rootCaPem: this.configModel.rootCaPem,
|
|
311
|
+
verifySsl: this.configModel.verifySsl,
|
|
312
|
+
});
|
|
313
|
+
this._transport = new RPCTransport({
|
|
314
|
+
eventDispatcher: this._dispatcher,
|
|
315
|
+
timeout: DEFAULT_SESSION_OPTIONS.timeouts.call,
|
|
316
|
+
onDisconnect: (error, closeCode) => this._handleTransportDisconnect(error, closeCode),
|
|
317
|
+
});
|
|
318
|
+
this._e2ee = new E2EEManager({
|
|
319
|
+
identityFn: () => this._identity ?? {},
|
|
320
|
+
deviceIdFn: () => this._deviceId,
|
|
321
|
+
keystore: this._keystore,
|
|
322
|
+
replayWindowSeconds: this.configModel.replayWindowSeconds,
|
|
323
|
+
});
|
|
324
|
+
this._groupE2ee = new GroupE2EEManager({
|
|
325
|
+
identityFn: () => this._identity ?? {},
|
|
326
|
+
keystore: this._keystore,
|
|
327
|
+
// 零信任:只返回经 PKI 验证的内存缓存证书
|
|
328
|
+
senderCertResolver: (aid) => this._getVerifiedPeerCert(aid),
|
|
329
|
+
initiatorCertResolver: (aid) => this._getVerifiedPeerCert(aid),
|
|
330
|
+
});
|
|
331
|
+
this.auth = new AuthNamespace(this);
|
|
332
|
+
this.custody = new CustodyNamespace(this);
|
|
333
|
+
// 内部订阅:推送消息自动解密后 re-publish 给用户
|
|
334
|
+
this._dispatcher.subscribe('_raw.message.received', (data) => {
|
|
335
|
+
this._onRawMessageReceived(data);
|
|
336
|
+
});
|
|
337
|
+
// 群组消息推送:自动解密后 re-publish
|
|
338
|
+
this._dispatcher.subscribe('_raw.group.message_created', (data) => {
|
|
339
|
+
this._onRawGroupMessageCreated(data);
|
|
340
|
+
});
|
|
341
|
+
// 群组变更事件:拦截处理成员变更触发的 epoch 轮换,然后透传
|
|
342
|
+
this._dispatcher.subscribe('_raw.group.changed', (data) => {
|
|
343
|
+
this._onRawGroupChanged(data);
|
|
344
|
+
});
|
|
345
|
+
// 其他事件直接透传
|
|
346
|
+
for (const evt of ['message.recalled', 'message.ack']) {
|
|
347
|
+
this._dispatcher.subscribe(`_raw.${evt}`, (data) => {
|
|
348
|
+
this._dispatcher.publish(evt, data);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
// 服务端主动断开通知:记录日志并标记不重连
|
|
352
|
+
this._dispatcher.subscribe('_raw.gateway.disconnect', (data) => {
|
|
353
|
+
this._onGatewayDisconnect(data);
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
// ── 属性 ──────────────────────────────────────────
|
|
357
|
+
get aid() {
|
|
358
|
+
return this._aid;
|
|
359
|
+
}
|
|
360
|
+
get state() {
|
|
361
|
+
return this._state;
|
|
362
|
+
}
|
|
363
|
+
get gatewayUrl() {
|
|
364
|
+
return this._gatewayUrl;
|
|
365
|
+
}
|
|
366
|
+
set gatewayUrl(url) {
|
|
367
|
+
this._gatewayUrl = url;
|
|
368
|
+
}
|
|
369
|
+
get discovery() {
|
|
370
|
+
return this._discovery;
|
|
371
|
+
}
|
|
372
|
+
/** 最近一次 health check 结果,null 表示尚未检查 */
|
|
373
|
+
get gatewayHealth() {
|
|
374
|
+
return this._discovery.lastHealthy;
|
|
375
|
+
}
|
|
376
|
+
/** 主动检查 gateway 可用性(GET /health) */
|
|
377
|
+
async checkGatewayHealth(gatewayUrl, timeout = 5000) {
|
|
378
|
+
return this._discovery.checkHealth(gatewayUrl, timeout);
|
|
379
|
+
}
|
|
380
|
+
get e2ee() {
|
|
381
|
+
return this._e2ee;
|
|
382
|
+
}
|
|
383
|
+
get groupE2ee() {
|
|
384
|
+
return this._groupE2ee;
|
|
385
|
+
}
|
|
386
|
+
// ── 生命周期 ──────────────────────────────────────
|
|
387
|
+
/**
|
|
388
|
+
* 连接到 Gateway。
|
|
389
|
+
*
|
|
390
|
+
* @param auth - 认证参数,必须包含 access_token 和 gateway
|
|
391
|
+
* @param options - 可选的会话选项(auto_reconnect, heartbeat_interval 等)
|
|
392
|
+
*/
|
|
393
|
+
async connect(auth, options) {
|
|
394
|
+
if (this._state !== 'idle' && this._state !== 'closed' && this._state !== 'disconnected') {
|
|
395
|
+
throw new StateError(`connect not allowed in state ${this._state}`);
|
|
396
|
+
}
|
|
397
|
+
const params = { ...auth, ...options };
|
|
398
|
+
const normalized = this._normalizeConnectParams(params);
|
|
399
|
+
this._sessionParams = normalized;
|
|
400
|
+
this._sessionOptions = this._buildSessionOptions(normalized);
|
|
401
|
+
this._transport.setTimeout(this._sessionOptions.timeouts.call);
|
|
402
|
+
this._closing = false;
|
|
403
|
+
await this._connectOnce(normalized, false);
|
|
404
|
+
}
|
|
405
|
+
/** 断开连接但保留本地状态,可再次 connect */
|
|
406
|
+
async disconnect() {
|
|
407
|
+
if (this._state !== 'connected' && this._state !== 'reconnecting') {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
this._saveSeqTrackerState();
|
|
411
|
+
this._stopBackgroundTasks();
|
|
412
|
+
if (this._reconnectAbort) {
|
|
413
|
+
this._reconnectAbort.abort();
|
|
414
|
+
this._reconnectAbort = null;
|
|
415
|
+
this._reconnectActive = false;
|
|
416
|
+
}
|
|
417
|
+
await this._transport.close();
|
|
418
|
+
this._state = 'disconnected';
|
|
419
|
+
await this._dispatcher.publish('connection.state', { state: this._state });
|
|
420
|
+
}
|
|
421
|
+
/** 列出本地所有已存储的身份摘要(仅返回有有效私钥的 AID) */
|
|
422
|
+
async listIdentities() {
|
|
423
|
+
const listFn = this._keystore.listIdentities;
|
|
424
|
+
if (typeof listFn !== 'function')
|
|
425
|
+
return [];
|
|
426
|
+
const aids = await listFn.call(this._keystore);
|
|
427
|
+
const summaries = [];
|
|
428
|
+
for (const aid of [...aids].sort()) {
|
|
429
|
+
const identity = await this._keystore.loadIdentity(aid);
|
|
430
|
+
if (!identity || !identity.private_key_pem)
|
|
431
|
+
continue;
|
|
432
|
+
const summary = { aid };
|
|
433
|
+
// 优先从 loadMetadata 获取
|
|
434
|
+
const loadMeta = this._keystore.loadMetadata;
|
|
435
|
+
if (typeof loadMeta === 'function') {
|
|
436
|
+
const md = await loadMeta.call(this._keystore, aid);
|
|
437
|
+
if (md && Object.keys(md).length > 0) {
|
|
438
|
+
summary.metadata = md;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// 回退:从 identity 中提取非核心字段
|
|
442
|
+
if (!summary.metadata) {
|
|
443
|
+
const metadata = {};
|
|
444
|
+
for (const [key, value] of Object.entries(identity)) {
|
|
445
|
+
if (!['aid', 'private_key_pem', 'public_key_der_b64', 'curve', 'cert'].includes(key)) {
|
|
446
|
+
metadata[key] = value;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (Object.keys(metadata).length > 0) {
|
|
450
|
+
summary.metadata = metadata;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
summaries.push(summary);
|
|
454
|
+
}
|
|
455
|
+
return summaries;
|
|
456
|
+
}
|
|
457
|
+
/** 关闭连接 */
|
|
458
|
+
async close() {
|
|
459
|
+
this._closing = true;
|
|
460
|
+
this._saveSeqTrackerState();
|
|
461
|
+
this._stopBackgroundTasks();
|
|
462
|
+
// 取消进行中的重连
|
|
463
|
+
if (this._reconnectAbort) {
|
|
464
|
+
this._reconnectAbort.abort();
|
|
465
|
+
this._reconnectAbort = null;
|
|
466
|
+
this._reconnectActive = false;
|
|
467
|
+
}
|
|
468
|
+
if (this._state === 'idle' || this._state === 'closed') {
|
|
469
|
+
this._state = 'closed';
|
|
470
|
+
this._resetSeqTrackingState();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
// 关闭前通知服务端主动退出(best-effort,失败不阻塞)
|
|
474
|
+
try {
|
|
475
|
+
await this._transport.call('auth.logout', {});
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
// auth.logout 失败不影响关闭流程
|
|
479
|
+
}
|
|
480
|
+
await this._transport.close();
|
|
481
|
+
this._state = 'closed';
|
|
482
|
+
await this._dispatcher.publish('connection.state', { state: this._state });
|
|
483
|
+
this._resetSeqTrackingState();
|
|
484
|
+
}
|
|
485
|
+
// ── RPC ───────────────────────────────────────────
|
|
486
|
+
/**
|
|
487
|
+
* 发起 RPC 调用。
|
|
488
|
+
*
|
|
489
|
+
* 自动拦截内部方法、自动加密 message.send/group.send、
|
|
490
|
+
* 自动解密 message.pull/group.pull、Group E2EE 生命周期编排。
|
|
491
|
+
*/
|
|
492
|
+
async call(method, params) {
|
|
493
|
+
if (this._state !== 'connected') {
|
|
494
|
+
throw new ConnectionError('client is not connected');
|
|
495
|
+
}
|
|
496
|
+
if (INTERNAL_ONLY_METHODS.has(method)) {
|
|
497
|
+
throw new PermissionError(`method is internal_only: ${method}`);
|
|
498
|
+
}
|
|
499
|
+
const p = { ...(params ?? {}) };
|
|
500
|
+
this._validateOutboundCall(method, p);
|
|
501
|
+
this._injectMessageCursorContext(method, p);
|
|
502
|
+
// group.* 方法注入 device_id(服务端用于多设备消息路由)
|
|
503
|
+
if (method.startsWith('group.') && this._deviceId && p.device_id === undefined) {
|
|
504
|
+
p.device_id = this._deviceId;
|
|
505
|
+
}
|
|
506
|
+
if (method.startsWith('group.') && p.slot_id === undefined) {
|
|
507
|
+
p.slot_id = this._slotId;
|
|
508
|
+
}
|
|
509
|
+
// 自动加密:message.send 默认加密(encrypt 默认 true)
|
|
510
|
+
if (method === 'message.send') {
|
|
511
|
+
const encrypt = p.encrypt !== undefined ? p.encrypt : true;
|
|
512
|
+
delete p.encrypt;
|
|
513
|
+
if (encrypt) {
|
|
514
|
+
return this._sendEncrypted(p);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
// 自动加密:group.send 默认加密(encrypt 默认 true)
|
|
518
|
+
if (method === 'group.send') {
|
|
519
|
+
const encrypt = p.encrypt !== undefined ? p.encrypt : true;
|
|
520
|
+
delete p.encrypt;
|
|
521
|
+
if (encrypt) {
|
|
522
|
+
return this._sendGroupEncrypted(p);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// 关键操作自动附加客户端签名
|
|
526
|
+
if (SIGNED_METHODS.has(method)) {
|
|
527
|
+
await this._signClientOperation(method, p);
|
|
528
|
+
}
|
|
529
|
+
const result = await this._transport.call(method, p);
|
|
530
|
+
// 自动解密:message.pull 返回的消息
|
|
531
|
+
if (method === 'message.pull' && isJsonObject(result)) {
|
|
532
|
+
const r = result;
|
|
533
|
+
const messages = r.messages;
|
|
534
|
+
const rawMessages = (Array.isArray(messages) ? messages : []).filter(isJsonObject);
|
|
535
|
+
if (rawMessages.length) {
|
|
536
|
+
r.messages = await this._decryptMessages(rawMessages);
|
|
537
|
+
}
|
|
538
|
+
if (this._aid) {
|
|
539
|
+
const ns = `p2p:${this._aid}`;
|
|
540
|
+
// 用原始 messages 喂 SeqTracker(解密去重前),与 Go/TS/Python 一致
|
|
541
|
+
if (rawMessages.length) {
|
|
542
|
+
this._seqTracker.onPullResult(ns, rawMessages);
|
|
543
|
+
}
|
|
544
|
+
// ⚠️ 逻辑边界 L1/L3:P2P retention floor 通道 = server_ack_seq
|
|
545
|
+
// 服务端在持久化/设备视图分支返回 server_ack_seq,客户端若 contiguous 落后必须 force 跳过
|
|
546
|
+
// retention window 外的空洞。与 S2 [1,seq-1] 历史 gap 配合;若去掉 force,首条消息建的 gap 会
|
|
547
|
+
// 永远悬挂触发无限 pull。临时消息淘汰走 ephemeral_earliest_available_seq(仅提示),与此互斥。
|
|
548
|
+
const serverAck = Number(r.server_ack_seq ?? 0);
|
|
549
|
+
if (serverAck > 0) {
|
|
550
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
551
|
+
if (contig < serverAck) {
|
|
552
|
+
console.info('[aun_core] message.pull retention-floor 推进: ns=' + ns + ' contiguous=' + contig + ' -> server_ack_seq=' + serverAck);
|
|
553
|
+
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
this._saveSeqTrackerState();
|
|
557
|
+
// auto-ack contiguous_seq
|
|
558
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
559
|
+
if (contig > 0 && (rawMessages.length > 0 || serverAck > 0)) {
|
|
560
|
+
this._transport.call('message.ack', {
|
|
561
|
+
seq: contig,
|
|
562
|
+
device_id: this._deviceId,
|
|
563
|
+
}).catch((e) => { console.warn('message.pull auto-ack 失败:', e); });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// 自动解密:group.pull 返回的群消息
|
|
568
|
+
if (method === 'group.pull' && isJsonObject(result)) {
|
|
569
|
+
const r = result;
|
|
570
|
+
const messages = r.messages;
|
|
571
|
+
// 先保存原始消息(解密前),用于喂 SeqTracker(与 P2P message.pull 路径对齐)
|
|
572
|
+
const rawMessages = (Array.isArray(messages) ? messages : []).filter(isJsonObject);
|
|
573
|
+
if (rawMessages.length) {
|
|
574
|
+
r.messages = await this._decryptGroupMessages(rawMessages);
|
|
575
|
+
}
|
|
576
|
+
const gid = (p.group_id ?? '');
|
|
577
|
+
if (gid) {
|
|
578
|
+
const ns = `group:${gid}`;
|
|
579
|
+
// ⚠️ 使用原始消息(rawMessages)喂 SeqTracker,与 P2P message.pull 路径一致
|
|
580
|
+
if (rawMessages.length) {
|
|
581
|
+
this._seqTracker.onPullResult(ns, rawMessages);
|
|
582
|
+
}
|
|
583
|
+
// ⚠️ 逻辑边界 L4:group retention floor 通道 = cursor.current_seq
|
|
584
|
+
// 群路径目前无独立 earliest_available_seq 字段;若未来引入 group retention,需新增字段并同步更新此处。
|
|
585
|
+
// 与 S2 [1,seq-1] 历史 gap 配合使用,force_contiguous_seq 是跳过空洞的唯一手段。
|
|
586
|
+
const cursor = isJsonObject(r.cursor) ? r.cursor : null;
|
|
587
|
+
if (cursor) {
|
|
588
|
+
const serverAck = Number(cursor.current_seq ?? 0);
|
|
589
|
+
if (serverAck > 0) {
|
|
590
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
591
|
+
if (contig < serverAck) {
|
|
592
|
+
console.info('[aun_core] group.pull retention-floor 推进: ns=' + ns + ' contiguous=' + contig + ' -> cursor.current_seq=' + serverAck);
|
|
593
|
+
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
this._saveSeqTrackerState();
|
|
598
|
+
// auto-ack contiguous_seq
|
|
599
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
600
|
+
const shouldAck = rawMessages.length > 0 || (cursor !== null && Number(cursor.current_seq ?? 0) > 0);
|
|
601
|
+
if (contig > 0 && shouldAck) {
|
|
602
|
+
this._transport.call('group.ack_messages', {
|
|
603
|
+
group_id: gid,
|
|
604
|
+
msg_seq: contig,
|
|
605
|
+
device_id: this._deviceId,
|
|
606
|
+
slot_id: this._slotId,
|
|
607
|
+
}).catch((e) => { console.warn('group.pull auto-ack 失败: group=' + gid, e); });
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// ── Group E2EE 自动编排 ────────────────────────────
|
|
612
|
+
// ── Group E2EE 自动编排(必备能力,始终启用)────────
|
|
613
|
+
{
|
|
614
|
+
// 建群后自动创建 epoch(幂等:已有 secret 时跳过)
|
|
615
|
+
if (method === 'group.create' && isJsonObject(result)) {
|
|
616
|
+
const group = isJsonObject(result.group) ? result.group : null;
|
|
617
|
+
const gid = String(group?.group_id ?? '');
|
|
618
|
+
if (gid && this._aid && !(await this._groupE2ee.hasSecret(gid))) {
|
|
619
|
+
try {
|
|
620
|
+
await this._groupE2ee.createEpoch(gid, [this._aid]);
|
|
621
|
+
// 同步到服务端:将服务端 epoch 从 0 推到 1;必须在 group.create 返回前完成,
|
|
622
|
+
// 否则调用方紧接着加成员时会让初始 rotation 因成员集变化而提交失败。
|
|
623
|
+
await this._syncEpochToServer(gid);
|
|
624
|
+
}
|
|
625
|
+
catch (exc) {
|
|
626
|
+
this._logE2eeError('create_epoch', gid, '', exc);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
// 入群类 RPC 的成员集变更统一由 group.changed 事件驱动 epoch 轮换。
|
|
631
|
+
}
|
|
632
|
+
// 成员集变更主要由 group.changed 事件驱动;RPC 成功返回路径做幂等兜底,避免事件丢失或延迟时不轮换。
|
|
633
|
+
const membershipMethods = new Set([
|
|
634
|
+
'group.add_member', 'group.kick', 'group.remove_member', 'group.leave',
|
|
635
|
+
'group.review_join_request', 'group.batch_review_join_request',
|
|
636
|
+
'group.use_invite_code', 'group.request_join',
|
|
637
|
+
]);
|
|
638
|
+
if (membershipMethods.has(method) && isJsonObject(result) && !('error' in result)) {
|
|
639
|
+
const groupId = this._extractGroupIdFromResult(result) || String(p.group_id ?? '');
|
|
640
|
+
if (groupId && this._membershipRotationChanged(method, result)) {
|
|
641
|
+
const expectedEpoch = this._membershipRotationExpectedEpoch(result);
|
|
642
|
+
this._safeAsync(this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch));
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return result;
|
|
646
|
+
}
|
|
647
|
+
// ── 便利方法 ──────────────────────────────────────
|
|
648
|
+
async ping(params) {
|
|
649
|
+
return this.call('meta.ping', params ?? {});
|
|
650
|
+
}
|
|
651
|
+
async status(params) {
|
|
652
|
+
return this.call('meta.status', params ?? {});
|
|
653
|
+
}
|
|
654
|
+
async trustRoots(params) {
|
|
655
|
+
return this.call('meta.trust_roots', params ?? {});
|
|
656
|
+
}
|
|
657
|
+
// ── 事件 ──────────────────────────────────────────
|
|
658
|
+
/**
|
|
659
|
+
* 订阅事件。
|
|
660
|
+
*
|
|
661
|
+
* 注意:off() 使用引用相等(===)匹配 handler,匿名函数将无法通过
|
|
662
|
+
* off() 取消订阅。建议使用返回的 Subscription 对象调用 unsubscribe()。
|
|
663
|
+
*/
|
|
664
|
+
on(event, handler) {
|
|
665
|
+
return this._dispatcher.subscribe(event, handler);
|
|
666
|
+
}
|
|
667
|
+
/** 取消订阅事件 */
|
|
668
|
+
off(event, handler) {
|
|
669
|
+
this._dispatcher.unsubscribe(event, handler);
|
|
670
|
+
}
|
|
671
|
+
// ── 事件管道:消息解密 ────────────────────────────
|
|
672
|
+
/** 处理 transport 层推送的原始消息:解密后 re-publish 给用户 */
|
|
673
|
+
_onRawMessageReceived(data) {
|
|
674
|
+
this._safeAsync(this._processAndPublishMessage(data));
|
|
675
|
+
}
|
|
676
|
+
/** 实际处理推送消息的异步任务 */
|
|
677
|
+
async _processAndPublishMessage(data) {
|
|
678
|
+
try {
|
|
679
|
+
if (!isJsonObject(data)) {
|
|
680
|
+
await this._dispatcher.publish('message.received', data);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const msg = { ...data };
|
|
684
|
+
// 拦截 P2P 传输的群组密钥分发/请求/响应消息
|
|
685
|
+
if (await this._tryHandleGroupKeyMessage(msg)) {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
// P2P 空洞检测
|
|
689
|
+
const seq = msg.seq;
|
|
690
|
+
// 推送路径收到 P2P 消息 → 标记已同步,后续发送无需再 lazySyncP2p
|
|
691
|
+
this._p2pSynced = true;
|
|
692
|
+
if (seq !== undefined && seq !== null && this._aid) {
|
|
693
|
+
const ns = `p2p:${this._aid}`;
|
|
694
|
+
const needPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
695
|
+
if (needPull) {
|
|
696
|
+
this._safeAsync(this._fillP2pGap());
|
|
697
|
+
}
|
|
698
|
+
// auto-ack contiguous_seq
|
|
699
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
700
|
+
if (contig > 0) {
|
|
701
|
+
this._transport.call('message.ack', {
|
|
702
|
+
seq: contig,
|
|
703
|
+
device_id: this._deviceId,
|
|
704
|
+
}).catch((e) => { console.warn('P2P auto-ack 失败:', e); });
|
|
705
|
+
}
|
|
706
|
+
// 即时持久化 cursor,异常断连后不回退
|
|
707
|
+
this._saveSeqTrackerState();
|
|
708
|
+
}
|
|
709
|
+
const decrypted = await this._decryptSingleMessage(msg);
|
|
710
|
+
// 记录已推送的 seq,补洞路径据此去重
|
|
711
|
+
if (seq !== undefined && seq !== null && this._aid) {
|
|
712
|
+
const ns = `p2p:${this._aid}`;
|
|
713
|
+
if (!this._pushedSeqs.has(ns))
|
|
714
|
+
this._pushedSeqs.set(ns, new Set());
|
|
715
|
+
this._pushedSeqs.get(ns).add(seq);
|
|
716
|
+
}
|
|
717
|
+
await this._dispatcher.publish('message.received', decrypted);
|
|
718
|
+
}
|
|
719
|
+
catch (exc) {
|
|
720
|
+
console.warn('消息解密失败:', exc);
|
|
721
|
+
// H26: 解密失败不再投递原始密文 payload(避免元数据泄漏 + 语义混淆),
|
|
722
|
+
// 改为发布 message.undecryptable 事件,仅携带安全的 header 信息。
|
|
723
|
+
if (isJsonObject(data)) {
|
|
724
|
+
const src = data;
|
|
725
|
+
const safeEvent = {
|
|
726
|
+
message_id: (src.message_id ?? null),
|
|
727
|
+
from: (src.from ?? null),
|
|
728
|
+
to: (src.to ?? null),
|
|
729
|
+
seq: (src.seq ?? null),
|
|
730
|
+
timestamp: (src.timestamp ?? null),
|
|
731
|
+
_decrypt_error: String(exc),
|
|
732
|
+
};
|
|
733
|
+
await this._dispatcher.publish('message.undecryptable', safeEvent);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
/** 处理群组消息推送:自动解密后 re-publish */
|
|
738
|
+
_onRawGroupMessageCreated(data) {
|
|
739
|
+
this._safeAsync(this._processAndPublishGroupMessage(data));
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* 处理群组推送消息的异步任务。
|
|
743
|
+
*
|
|
744
|
+
* 带 payload 的事件(消息推送):解密后 re-publish。
|
|
745
|
+
* 不带 payload 的事件(通知):自动 pull 最新消息,逐条解密后 re-publish。
|
|
746
|
+
*/
|
|
747
|
+
async _processAndPublishGroupMessage(data) {
|
|
748
|
+
try {
|
|
749
|
+
if (!isJsonObject(data)) {
|
|
750
|
+
await this._dispatcher.publish('group.message_created', data);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
const msg = { ...data };
|
|
754
|
+
const groupId = (msg.group_id ?? '');
|
|
755
|
+
const seq = msg.seq;
|
|
756
|
+
const payload = msg.payload;
|
|
757
|
+
// 推送路径收到群消息 → 标记已同步,后续发送无需再 lazySyncGroup
|
|
758
|
+
if (groupId) {
|
|
759
|
+
this._groupSynced.add(groupId);
|
|
760
|
+
}
|
|
761
|
+
if (payload === undefined || payload === null
|
|
762
|
+
|| (typeof payload === 'object' && Object.keys(payload).length === 0)) {
|
|
763
|
+
// 不带 payload 的通知不能先推进 seq,否则 auto-pull 会用推进后的 cursor 跳过该消息。
|
|
764
|
+
await this._autoPullGroupMessages(msg);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const decrypted = await this._decryptGroupMessage(msg);
|
|
768
|
+
// 只有带 payload 的真实消息,在同步解密/恢复尝试结束后才推进游标。
|
|
769
|
+
if (groupId && seq !== undefined && seq !== null) {
|
|
770
|
+
const ns = `group:${groupId}`;
|
|
771
|
+
const needPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
772
|
+
if (needPull) {
|
|
773
|
+
this._safeAsync(this._fillGroupGap(groupId));
|
|
774
|
+
}
|
|
775
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
776
|
+
if (contig > 0) {
|
|
777
|
+
this._transport.call('group.ack_messages', {
|
|
778
|
+
group_id: groupId,
|
|
779
|
+
msg_seq: contig,
|
|
780
|
+
device_id: this._deviceId,
|
|
781
|
+
slot_id: this._slotId,
|
|
782
|
+
}).catch((e) => { console.warn('群消息 auto-ack 失败: group=' + groupId, e); });
|
|
783
|
+
}
|
|
784
|
+
this._saveSeqTrackerState();
|
|
785
|
+
}
|
|
786
|
+
// 记录已推送的 seq,补洞路径据此去重
|
|
787
|
+
if (groupId && seq !== undefined && seq !== null) {
|
|
788
|
+
const nsKey = `group:${groupId}`;
|
|
789
|
+
if (!this._pushedSeqs.has(nsKey))
|
|
790
|
+
this._pushedSeqs.set(nsKey, new Set());
|
|
791
|
+
this._pushedSeqs.get(nsKey).add(seq);
|
|
792
|
+
}
|
|
793
|
+
// R3: 解密失败 → 不 publish 密文给应用层,入 pending 队列 + 发 undecryptable 事件
|
|
794
|
+
const payloadForCheck = isJsonObject(msg.payload) ? msg.payload : null;
|
|
795
|
+
if (payloadForCheck?.type === 'e2ee.group_encrypted' && !decrypted.e2ee) {
|
|
796
|
+
if (groupId)
|
|
797
|
+
this._enqueuePendingDecrypt(groupId, msg);
|
|
798
|
+
await this._dispatcher.publish('group.message_undecryptable', {
|
|
799
|
+
message_id: msg.message_id ?? null,
|
|
800
|
+
group_id: groupId,
|
|
801
|
+
from: msg.from ?? null,
|
|
802
|
+
seq,
|
|
803
|
+
timestamp: msg.timestamp ?? null,
|
|
804
|
+
_decrypt_error: 'group secret unavailable',
|
|
805
|
+
});
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
await this._dispatcher.publish('group.message_created', decrypted);
|
|
809
|
+
}
|
|
810
|
+
catch (exc) {
|
|
811
|
+
console.warn('群消息解密失败:', exc);
|
|
812
|
+
// H26: 解密失败改发 group.message_undecryptable 事件,不投递原始密文 payload。
|
|
813
|
+
if (isJsonObject(data)) {
|
|
814
|
+
const src = data;
|
|
815
|
+
const safeEvent = {
|
|
816
|
+
message_id: (src.message_id ?? null),
|
|
817
|
+
group_id: (src.group_id ?? null),
|
|
818
|
+
from: (src.from ?? null),
|
|
819
|
+
seq: (src.seq ?? null),
|
|
820
|
+
timestamp: (src.timestamp ?? null),
|
|
821
|
+
_decrypt_error: String(exc),
|
|
822
|
+
};
|
|
823
|
+
await this._dispatcher.publish('group.message_undecryptable', safeEvent);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
/** 收到不带 payload 的 group.message_created 通知后,自动 pull 最新消息 */
|
|
828
|
+
async _autoPullGroupMessages(notification) {
|
|
829
|
+
const groupId = (notification.group_id ?? '');
|
|
830
|
+
if (!groupId) {
|
|
831
|
+
await this._dispatcher.publish('group.message_created', notification);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
const ns = `group:${groupId}`;
|
|
835
|
+
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
836
|
+
try {
|
|
837
|
+
const result = await this.call('group.pull', {
|
|
838
|
+
group_id: groupId,
|
|
839
|
+
after_message_seq: afterSeq,
|
|
840
|
+
device_id: this._deviceId,
|
|
841
|
+
limit: 50,
|
|
842
|
+
});
|
|
843
|
+
if (isJsonObject(result)) {
|
|
844
|
+
const messages = result.messages;
|
|
845
|
+
if (Array.isArray(messages)) {
|
|
846
|
+
// ⚠️ 不再重复调用 onPullResult:call('group.pull') 拦截器已在内部调用过一次
|
|
847
|
+
// pushedSeqs 去重:跳过已通过推送路径分发的消息
|
|
848
|
+
const pushed = this._pushedSeqs.get(ns);
|
|
849
|
+
for (const msg of messages) {
|
|
850
|
+
if (isJsonObject(msg)) {
|
|
851
|
+
const s = msg.seq;
|
|
852
|
+
if (pushed && s !== undefined && s !== null && pushed.has(s)) {
|
|
853
|
+
continue; // 已通过推送路径分发,跳过
|
|
854
|
+
}
|
|
855
|
+
await this._dispatcher.publish('group.message_created', msg);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
this._prunePushedSeqs(ns);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
catch (exc) {
|
|
864
|
+
console.warn('自动 pull 群消息失败:', exc);
|
|
865
|
+
}
|
|
866
|
+
// pull 失败时仍透传原始通知
|
|
867
|
+
await this._dispatcher.publish('group.message_created', notification);
|
|
868
|
+
}
|
|
869
|
+
/** 后台补齐群消息空洞 */
|
|
870
|
+
async _fillGroupGap(groupId) {
|
|
871
|
+
// 状态保护:非 connected 或正在关闭时跳过(与 Python 对齐)
|
|
872
|
+
if (this._state !== 'connected' || this._closing)
|
|
873
|
+
return;
|
|
874
|
+
const ns = `group:${groupId}`;
|
|
875
|
+
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
876
|
+
// 冷启动(seq=0):服务端推送会带全量消息,SDK 不主动补洞避免重复拉取
|
|
877
|
+
if (afterSeq === 0) {
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
// 去重:同一 (group:id:after_seq) 在飞行中只补一次
|
|
881
|
+
// S1: 使用 try/finally 保证无论成功失败都清理 dedupKey,避免旧实现只在异常路径清理
|
|
882
|
+
// 导致成功后相同 afterSeq 再次出现空洞时被永久抑制。
|
|
883
|
+
const dedupKey = `group_msg:${groupId}:${afterSeq}`;
|
|
884
|
+
if (this._gapFillDone.has(dedupKey))
|
|
885
|
+
return;
|
|
886
|
+
this._gapFillDone.add(dedupKey);
|
|
887
|
+
this._gapFillActive = true;
|
|
888
|
+
try {
|
|
889
|
+
const result = await this.call('group.pull', {
|
|
890
|
+
group_id: groupId,
|
|
891
|
+
after_message_seq: afterSeq,
|
|
892
|
+
device_id: this._deviceId,
|
|
893
|
+
limit: 50,
|
|
894
|
+
});
|
|
895
|
+
if (isJsonObject(result)) {
|
|
896
|
+
const messages = result.messages;
|
|
897
|
+
if (Array.isArray(messages)) {
|
|
898
|
+
// ⚠️ 不再重复调用 onPullResult:call('group.pull') 拦截器已在内部调用过一次
|
|
899
|
+
const pushed = this._pushedSeqs.get(ns);
|
|
900
|
+
for (const msg of messages) {
|
|
901
|
+
if (isJsonObject(msg)) {
|
|
902
|
+
const s = msg.seq;
|
|
903
|
+
if (pushed && s !== undefined && s !== null && pushed.has(s))
|
|
904
|
+
continue;
|
|
905
|
+
await this._dispatcher.publish('group.message_created', msg);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
this._prunePushedSeqs(ns);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
catch (exc) {
|
|
913
|
+
console.warn('[aun_core] 群消息补洞失败:', exc);
|
|
914
|
+
}
|
|
915
|
+
finally {
|
|
916
|
+
// S1: 成功 / 失败路径都必须清理飞行标记
|
|
917
|
+
this._gapFillDone.delete(dedupKey);
|
|
918
|
+
this._gapFillActive = false;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
/** 后台补齐群事件空洞 */
|
|
922
|
+
async _fillGroupEventGap(groupId) {
|
|
923
|
+
// 状态保护:非 connected 或正在关闭时跳过(与 Python 对齐)
|
|
924
|
+
if (this._state !== 'connected' || this._closing)
|
|
925
|
+
return;
|
|
926
|
+
const ns = `group_event:${groupId}`;
|
|
927
|
+
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
928
|
+
// 冷启动(seq=0):服务端推送会带全量事件,SDK 不主动补洞避免重复拉取
|
|
929
|
+
if (afterSeq === 0) {
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
// 去重:同一 (group_evt:id:after_seq) 在飞行中只补一次
|
|
933
|
+
// S1: 使用 try/finally 保证无论成功失败都清理 dedupKey
|
|
934
|
+
const dedupKey = `group_evt:${groupId}:${afterSeq}`;
|
|
935
|
+
if (this._gapFillDone.has(dedupKey))
|
|
936
|
+
return;
|
|
937
|
+
this._gapFillDone.add(dedupKey);
|
|
938
|
+
this._gapFillActive = true;
|
|
939
|
+
try {
|
|
940
|
+
const result = await this.call('group.pull_events', {
|
|
941
|
+
group_id: groupId,
|
|
942
|
+
after_event_seq: afterSeq,
|
|
943
|
+
device_id: this._deviceId,
|
|
944
|
+
limit: 50,
|
|
945
|
+
});
|
|
946
|
+
if (isJsonObject(result)) {
|
|
947
|
+
const events = result.events;
|
|
948
|
+
if (Array.isArray(events)) {
|
|
949
|
+
this._seqTracker.onPullResult(ns, events.filter(isJsonObject));
|
|
950
|
+
// 持久化 cursor + ack_events(与 Python 对齐)
|
|
951
|
+
this._saveSeqTrackerState();
|
|
952
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
953
|
+
if (contig > 0) {
|
|
954
|
+
this._transport.call('group.ack_events', {
|
|
955
|
+
group_id: groupId,
|
|
956
|
+
event_seq: contig,
|
|
957
|
+
device_id: this._deviceId,
|
|
958
|
+
}).catch((e) => { console.warn('群事件 auto-ack 失败: group=' + groupId, e); });
|
|
959
|
+
}
|
|
960
|
+
for (const evt of events) {
|
|
961
|
+
if (isJsonObject(evt)) {
|
|
962
|
+
evt._from_gap_fill = true;
|
|
963
|
+
const et = String(evt.event_type ?? '');
|
|
964
|
+
// 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
|
|
965
|
+
if (et === 'group.message_created')
|
|
966
|
+
continue;
|
|
967
|
+
// group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
|
|
968
|
+
await this._dispatcher.publish('group.changed', evt);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
catch (exc) {
|
|
975
|
+
console.warn('[aun_core] 群事件补洞失败:', exc);
|
|
976
|
+
}
|
|
977
|
+
finally {
|
|
978
|
+
// S1: 成功 / 失败路径都必须清理飞行标记
|
|
979
|
+
this._gapFillDone.delete(dedupKey);
|
|
980
|
+
this._gapFillActive = false;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
/** 后台补齐 P2P 消息空洞 */
|
|
984
|
+
async _fillP2pGap() {
|
|
985
|
+
// 状态保护:非 connected 或正在关闭时跳过(与 Python 对齐)
|
|
986
|
+
if (this._state !== 'connected' || this._closing)
|
|
987
|
+
return;
|
|
988
|
+
if (!this._aid)
|
|
989
|
+
return;
|
|
990
|
+
const ns = `p2p:${this._aid}`;
|
|
991
|
+
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
992
|
+
// 新设备(seq=0)没有历史 prekey,拉旧消息也解不了
|
|
993
|
+
if (afterSeq === 0) {
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
// 去重:同一 (type:after_seq) 在飞行中只补一次
|
|
997
|
+
// S1: 使用 try/finally 保证无论成功失败都清理 dedupKey
|
|
998
|
+
const dedupKey = `p2p:${afterSeq}`;
|
|
999
|
+
if (this._gapFillDone.has(dedupKey))
|
|
1000
|
+
return;
|
|
1001
|
+
this._gapFillDone.add(dedupKey);
|
|
1002
|
+
this._gapFillActive = true;
|
|
1003
|
+
try {
|
|
1004
|
+
const result = await this.call('message.pull', {
|
|
1005
|
+
after_seq: afterSeq,
|
|
1006
|
+
limit: 50,
|
|
1007
|
+
});
|
|
1008
|
+
if (isJsonObject(result)) {
|
|
1009
|
+
const messages = result.messages;
|
|
1010
|
+
if (Array.isArray(messages)) {
|
|
1011
|
+
// ⚠️ 不再重复调用 onPullResult:call('message.pull') 拦截器已在内部调用过一次
|
|
1012
|
+
// 与 _fillGroupGap 路径对齐,避免双重 tracker 推进。
|
|
1013
|
+
const pushed = this._pushedSeqs.get(ns);
|
|
1014
|
+
for (const msg of messages) {
|
|
1015
|
+
if (isJsonObject(msg)) {
|
|
1016
|
+
const s = msg.seq;
|
|
1017
|
+
if (pushed && s !== undefined && s !== null && pushed.has(s))
|
|
1018
|
+
continue;
|
|
1019
|
+
await this._dispatcher.publish('message.received', msg);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
this._prunePushedSeqs(ns);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
catch (exc) {
|
|
1027
|
+
console.warn('[aun_core] P2P 消息补洞失败:', exc);
|
|
1028
|
+
}
|
|
1029
|
+
finally {
|
|
1030
|
+
// S1: 成功 / 失败路径都必须清理飞行标记
|
|
1031
|
+
this._gapFillDone.delete(dedupKey);
|
|
1032
|
+
this._gapFillActive = false;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
/** 清理 pushedSeqs 中 <= contiguousSeq 的条目,防止无限增长 */
|
|
1036
|
+
_prunePushedSeqs(ns) {
|
|
1037
|
+
const pushed = this._pushedSeqs.get(ns);
|
|
1038
|
+
if (!pushed)
|
|
1039
|
+
return;
|
|
1040
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1041
|
+
for (const s of pushed) {
|
|
1042
|
+
if (s <= contig)
|
|
1043
|
+
pushed.delete(s);
|
|
1044
|
+
}
|
|
1045
|
+
if (pushed.size === 0)
|
|
1046
|
+
this._pushedSeqs.delete(ns);
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* 上线/重连后一次性同步所有已加入群:
|
|
1050
|
+
* 1. 有 epoch key 的群 → 补消息 + 补事件
|
|
1051
|
+
* 2. 无 epoch key 的群 → 主动向 owner 请求密钥恢复 + 补事件
|
|
1052
|
+
*/
|
|
1053
|
+
async _syncAllGroupsOnce() {
|
|
1054
|
+
try {
|
|
1055
|
+
const result = await this.call('group.list_my', {});
|
|
1056
|
+
if (!isJsonObject(result))
|
|
1057
|
+
return;
|
|
1058
|
+
const items = result.items;
|
|
1059
|
+
if (!Array.isArray(items))
|
|
1060
|
+
return;
|
|
1061
|
+
for (const g of items) {
|
|
1062
|
+
if (isJsonObject(g)) {
|
|
1063
|
+
const gid = (g.group_id ?? '');
|
|
1064
|
+
if (gid) {
|
|
1065
|
+
const hasSecret = await this._groupE2ee.hasSecret(gid);
|
|
1066
|
+
if (!hasSecret) {
|
|
1067
|
+
// 没有 epoch key → 主动向 owner 请求密钥恢复(与 Python 对齐)
|
|
1068
|
+
const ownerAid = (g.owner_aid ?? '');
|
|
1069
|
+
if (ownerAid && ownerAid !== this._aid) {
|
|
1070
|
+
await this._requestGroupKeyFrom(gid, ownerAid);
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
console.debug(`[aun_core] 群 ${gid} 无 epoch key 且无法确定 owner,等待推送触发恢复`);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
else {
|
|
1077
|
+
// 有 epoch key → 补消息
|
|
1078
|
+
await this._fillGroupGap(gid);
|
|
1079
|
+
}
|
|
1080
|
+
// 所有群都补事件(事件不加密)
|
|
1081
|
+
await this._fillGroupEventGap(gid);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
catch (exc) {
|
|
1087
|
+
console.warn('[aun_core] 上线群组同步失败,群消息可能不完整:', exc);
|
|
1088
|
+
this._dispatcher.publish('group.sync_failed', {
|
|
1089
|
+
error: exc instanceof Error ? exc.message : String(exc),
|
|
1090
|
+
}).catch(() => { });
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
/** 主动向指定成员请求群组密钥(用于重连时无 epoch key 的群)(与 Python 对齐) */
|
|
1094
|
+
async _requestGroupKeyFrom(groupId, targetAid, epoch = 0) {
|
|
1095
|
+
try {
|
|
1096
|
+
const reqPayload = buildKeyRequest(groupId, epoch, this._aid || '');
|
|
1097
|
+
this._groupE2ee.rememberKeyRequest(reqPayload, targetAid);
|
|
1098
|
+
await this.call('message.send', {
|
|
1099
|
+
to: targetAid,
|
|
1100
|
+
payload: reqPayload,
|
|
1101
|
+
encrypt: true,
|
|
1102
|
+
persist_required: true,
|
|
1103
|
+
});
|
|
1104
|
+
console.info(`[aun_core] 已向 ${targetAid} 请求群 ${groupId} 的密钥`);
|
|
1105
|
+
}
|
|
1106
|
+
catch (exc) {
|
|
1107
|
+
console.warn(`[aun_core] 向 ${targetAid} 请求群 ${groupId} 密钥失败:`, exc);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* 处理群组变更事件:透传给用户,并在成员离开/被踢时自动触发 epoch 轮换。
|
|
1112
|
+
* 按协议,轮换由剩余在线 admin/owner 负责。
|
|
1113
|
+
*/
|
|
1114
|
+
_membershipRotationExpectedEpoch(payload) {
|
|
1115
|
+
for (const key of ['old_epoch', 'current_epoch', 'e2ee_epoch']) {
|
|
1116
|
+
const value = payload[key];
|
|
1117
|
+
if (value !== undefined && value !== null) {
|
|
1118
|
+
const n = Number(value);
|
|
1119
|
+
if (Number.isFinite(n))
|
|
1120
|
+
return n;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
const group = payload.group;
|
|
1124
|
+
if (isJsonObject(group)) {
|
|
1125
|
+
for (const key of ['old_epoch', 'current_epoch', 'e2ee_epoch']) {
|
|
1126
|
+
const value = group[key];
|
|
1127
|
+
if (value !== undefined && value !== null) {
|
|
1128
|
+
const n = Number(value);
|
|
1129
|
+
if (Number.isFinite(n))
|
|
1130
|
+
return n;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
return null;
|
|
1135
|
+
}
|
|
1136
|
+
_membershipRotationTriggerId(groupId, payload) {
|
|
1137
|
+
let action = String(payload.action ?? '').trim();
|
|
1138
|
+
if (!action) {
|
|
1139
|
+
if (payload.removed_aid)
|
|
1140
|
+
action = 'member_removed';
|
|
1141
|
+
else if (payload.left_aid)
|
|
1142
|
+
action = 'member_left';
|
|
1143
|
+
else if (isJsonObject(payload.member))
|
|
1144
|
+
action = 'member_added';
|
|
1145
|
+
else
|
|
1146
|
+
action = String(payload.status ?? payload.reason ?? 'membership_changed');
|
|
1147
|
+
}
|
|
1148
|
+
const group = isJsonObject(payload.group) ? payload.group : null;
|
|
1149
|
+
const eventSeq = payload.event_seq ?? payload.seq ?? group?.event_seq ?? '';
|
|
1150
|
+
const changedAids = new Set();
|
|
1151
|
+
let changedAid = String(payload.aid ?? payload.removed_aid ?? payload.left_aid ?? payload.member_aid ?? payload.target_aid ?? '');
|
|
1152
|
+
if (changedAid)
|
|
1153
|
+
changedAids.add(changedAid);
|
|
1154
|
+
const member = isJsonObject(payload.member) ? payload.member : null;
|
|
1155
|
+
if (member?.aid)
|
|
1156
|
+
changedAids.add(String(member.aid));
|
|
1157
|
+
if (!changedAid && member)
|
|
1158
|
+
changedAid = String(member.aid ?? '');
|
|
1159
|
+
const request = isJsonObject(payload.request) ? payload.request : null;
|
|
1160
|
+
if (request?.aid)
|
|
1161
|
+
changedAids.add(String(request.aid));
|
|
1162
|
+
if (!changedAid && request)
|
|
1163
|
+
changedAid = String(request.aid ?? '');
|
|
1164
|
+
const inviteCode = isJsonObject(payload.invite_code) ? payload.invite_code : null;
|
|
1165
|
+
if (inviteCode?.used_by)
|
|
1166
|
+
changedAids.add(String(inviteCode.used_by));
|
|
1167
|
+
if (inviteCode?.aid)
|
|
1168
|
+
changedAids.add(String(inviteCode.aid));
|
|
1169
|
+
if (!changedAid && inviteCode)
|
|
1170
|
+
changedAid = String(inviteCode.used_by ?? inviteCode.aid ?? '');
|
|
1171
|
+
if (Array.isArray(payload.results)) {
|
|
1172
|
+
for (const item of payload.results) {
|
|
1173
|
+
if (!isJsonObject(item))
|
|
1174
|
+
continue;
|
|
1175
|
+
const status = String(item.status ?? '').trim().toLowerCase();
|
|
1176
|
+
if (status !== 'approved' && item.approved !== true)
|
|
1177
|
+
continue;
|
|
1178
|
+
for (const key of ['aid', 'member_aid', 'target_aid']) {
|
|
1179
|
+
const value = item[key];
|
|
1180
|
+
if (value !== undefined && value !== null && String(value))
|
|
1181
|
+
changedAids.add(String(value));
|
|
1182
|
+
}
|
|
1183
|
+
for (const key of ['member', 'request']) {
|
|
1184
|
+
const nested = isJsonObject(item[key]) ? item[key] : null;
|
|
1185
|
+
if (nested?.aid)
|
|
1186
|
+
changedAids.add(String(nested.aid));
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
const changedAidKey = Array.from(changedAids).filter(Boolean).sort().join(',');
|
|
1191
|
+
const expectedEpoch = this._membershipRotationExpectedEpoch(payload);
|
|
1192
|
+
if (changedAidKey && expectedEpoch !== null)
|
|
1193
|
+
return `${groupId}:${action}:aid:${changedAidKey}:epoch:${expectedEpoch}`;
|
|
1194
|
+
if (eventSeq !== undefined && eventSeq !== null && String(eventSeq) !== '')
|
|
1195
|
+
return `${groupId}:${action}:event:${String(eventSeq)}`;
|
|
1196
|
+
return `${groupId}:${action}:aid:${changedAidKey || changedAid || '-'}`;
|
|
1197
|
+
}
|
|
1198
|
+
_membershipRotationChanged(method, payload) {
|
|
1199
|
+
if (['group.add_member', 'group.kick', 'group.remove_member', 'group.leave'].includes(method)) {
|
|
1200
|
+
return true;
|
|
1201
|
+
}
|
|
1202
|
+
const status = String(payload.status ?? '').trim().toLowerCase();
|
|
1203
|
+
if (['group.use_invite_code', 'group.request_join'].includes(method)) {
|
|
1204
|
+
return ['joined', 'approved'].includes(status) || isJsonObject(payload.member);
|
|
1205
|
+
}
|
|
1206
|
+
if (method === 'group.review_join_request') {
|
|
1207
|
+
return status === 'approved' || payload.approved === true || isJsonObject(payload.member);
|
|
1208
|
+
}
|
|
1209
|
+
if (method === 'group.batch_review_join_request') {
|
|
1210
|
+
const results = payload.results;
|
|
1211
|
+
if (!Array.isArray(results))
|
|
1212
|
+
return false;
|
|
1213
|
+
return results.some((item) => {
|
|
1214
|
+
if (!isJsonObject(item))
|
|
1215
|
+
return false;
|
|
1216
|
+
const itemStatus = String(item.status ?? '').trim().toLowerCase();
|
|
1217
|
+
return itemStatus === 'approved' || item.approved === true;
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
return false;
|
|
1221
|
+
}
|
|
1222
|
+
_extractGroupIdFromResult(result) {
|
|
1223
|
+
const group = isJsonObject(result.group) ? result.group : null;
|
|
1224
|
+
const gid = group ? String(group.group_id ?? '') : '';
|
|
1225
|
+
if (gid)
|
|
1226
|
+
return gid;
|
|
1227
|
+
const directGid = String(result.group_id ?? '');
|
|
1228
|
+
if (directGid)
|
|
1229
|
+
return directGid;
|
|
1230
|
+
const member = isJsonObject(result.member) ? result.member : null;
|
|
1231
|
+
return member ? String(member.group_id ?? '') : '';
|
|
1232
|
+
}
|
|
1233
|
+
async _onRawGroupChanged(data) {
|
|
1234
|
+
if (isJsonObject(data)) {
|
|
1235
|
+
const d = data;
|
|
1236
|
+
// 验签:有 client_signature 就验,没有默认安全
|
|
1237
|
+
const cs = d.client_signature;
|
|
1238
|
+
if (cs && isJsonObject(cs)) {
|
|
1239
|
+
d._verified = await this._verifyEventSignature(d, cs);
|
|
1240
|
+
}
|
|
1241
|
+
await this._dispatcher.publish('group.changed', d);
|
|
1242
|
+
const groupId = (d.group_id ?? '');
|
|
1243
|
+
// event_seq 空洞检测:持久化后的 group.changed 会携带 event_seq。
|
|
1244
|
+
// 用 onMessageSeq 返回值决定是否补拉,与 P2P / group.message 路径对齐。
|
|
1245
|
+
let needPull = false;
|
|
1246
|
+
const rawEventSeq = d.event_seq;
|
|
1247
|
+
if (rawEventSeq != null && groupId) {
|
|
1248
|
+
const es = Number(rawEventSeq);
|
|
1249
|
+
if (Number.isFinite(es) && es > 0) {
|
|
1250
|
+
needPull = this._seqTracker.onMessageSeq(`group_event:${groupId}`, es);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
// 仅在真实 event gap 时才触发补拉(补洞回来的事件不再触发新补洞)
|
|
1254
|
+
if (needPull && groupId && !d._from_gap_fill) {
|
|
1255
|
+
this._safeAsync(this._fillGroupEventGap(groupId));
|
|
1256
|
+
}
|
|
1257
|
+
if (d.action === 'member_left' || d.action === 'member_removed') {
|
|
1258
|
+
if (groupId) {
|
|
1259
|
+
const expectedEpoch = this._membershipRotationExpectedEpoch(d);
|
|
1260
|
+
if (expectedEpoch === null) {
|
|
1261
|
+
console.debug('membership event without old_epoch skipped for epoch rotation: aid=%s group=%s action=%s event_seq=%s', this._aid ?? '', groupId, String(d.action ?? ''), String(d.event_seq ?? ''));
|
|
1262
|
+
}
|
|
1263
|
+
else {
|
|
1264
|
+
this._safeAsync(this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch));
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
if (['member_added', 'joined', 'join_approved', 'invite_code_used'].includes(String(d.action ?? ''))) {
|
|
1269
|
+
if (groupId) {
|
|
1270
|
+
const expectedEpoch = this._membershipRotationExpectedEpoch(d);
|
|
1271
|
+
if (expectedEpoch === null) {
|
|
1272
|
+
console.debug('membership event without old_epoch skipped for epoch rotation: aid=%s group=%s action=%s event_seq=%s', this._aid ?? '', groupId, String(d.action ?? ''), String(d.event_seq ?? ''));
|
|
1273
|
+
}
|
|
1274
|
+
else {
|
|
1275
|
+
this._safeAsync(this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch));
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
// 群组解散 → 清理本地 epoch key、seq_tracker、补洞去重缓存
|
|
1280
|
+
if (d.action === 'dissolved') {
|
|
1281
|
+
if (groupId) {
|
|
1282
|
+
this._cleanupDissolvedGroup(groupId);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
else {
|
|
1287
|
+
// data 非对象也透传给用户(兼容旧版)
|
|
1288
|
+
await this._dispatcher.publish('group.changed', data);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* 群组解散后清理本地状态:
|
|
1293
|
+
* - keystore 中的 epoch key 数据
|
|
1294
|
+
* - seq_tracker 中的群消息和群事件 seq 记录
|
|
1295
|
+
* - 补洞去重缓存中的相关条目
|
|
1296
|
+
* - 推送 seq 去重缓存
|
|
1297
|
+
*/
|
|
1298
|
+
_cleanupDissolvedGroup(groupId) {
|
|
1299
|
+
// 1. 清理 GroupE2EEManager / keystore 中的 epoch 密钥
|
|
1300
|
+
this._safeAsync(this._groupE2ee.removeGroup(groupId).catch((exc) => {
|
|
1301
|
+
console.warn(`[aun_core] 清理解散群组 ${groupId} epoch 密钥失败:`, exc);
|
|
1302
|
+
}));
|
|
1303
|
+
// 2. 清理 seq_tracker 中的群消息和群事件命名空间
|
|
1304
|
+
this._seqTracker.removeNamespace(`group:${groupId}`);
|
|
1305
|
+
this._seqTracker.removeNamespace(`group_event:${groupId}`);
|
|
1306
|
+
this._saveSeqTrackerState();
|
|
1307
|
+
// 3. 清理补洞去重缓存中的相关条目
|
|
1308
|
+
for (const key of this._gapFillDone.keys()) {
|
|
1309
|
+
if (key.includes(groupId)) {
|
|
1310
|
+
this._gapFillDone.delete(key);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
// 4. 清理推送 seq 去重缓存
|
|
1314
|
+
this._pushedSeqs.delete(`group:${groupId}`);
|
|
1315
|
+
this._pushedSeqs.delete(`group_event:${groupId}`);
|
|
1316
|
+
console.info(`[aun_core] 已清理解散群组 ${groupId} 的本地状态`);
|
|
1317
|
+
}
|
|
1318
|
+
async _verifyEventSignature(_event, cs) {
|
|
1319
|
+
const sigAid = String(cs.aid ?? '');
|
|
1320
|
+
const method = String(cs._method ?? '');
|
|
1321
|
+
const expectedFP = String(cs.cert_fingerprint ?? '').trim().toLowerCase();
|
|
1322
|
+
if (!sigAid || !method)
|
|
1323
|
+
return 'pending';
|
|
1324
|
+
const cached = this._certCache.get(certCacheKey(sigAid, expectedFP || undefined));
|
|
1325
|
+
if (!cached || !cached.certPem) {
|
|
1326
|
+
this._safeAsync(this._fetchPeerCert(sigAid, expectedFP || undefined));
|
|
1327
|
+
return 'pending';
|
|
1328
|
+
}
|
|
1329
|
+
try {
|
|
1330
|
+
if (expectedFP) {
|
|
1331
|
+
const actualFP = await certificateSha256Fingerprint(cached.certPem);
|
|
1332
|
+
if (actualFP !== expectedFP) {
|
|
1333
|
+
console.warn('[aun_core] 群事件验签失败:证书指纹不匹配 aid=%s', sigAid);
|
|
1334
|
+
return false;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
const paramsHash = String(cs.params_hash ?? '');
|
|
1338
|
+
const timestamp = String(cs.timestamp ?? '');
|
|
1339
|
+
const sigB64 = String(cs.signature ?? '');
|
|
1340
|
+
if (!paramsHash || !timestamp || !sigB64) {
|
|
1341
|
+
return false;
|
|
1342
|
+
}
|
|
1343
|
+
const pubKey = await importCertPublicKeyEcdsa(cached.certPem);
|
|
1344
|
+
const signData = new TextEncoder().encode(`${method}|${sigAid}|${timestamp}|${paramsHash}`);
|
|
1345
|
+
const sigBytes = base64ToUint8(sigB64);
|
|
1346
|
+
const ok = await ecdsaVerifyDer(pubKey, sigBytes, signData);
|
|
1347
|
+
if (!ok) {
|
|
1348
|
+
console.warn('[aun_core] 群事件验签失败 aid=%s method=%s', sigAid, method);
|
|
1349
|
+
}
|
|
1350
|
+
return ok;
|
|
1351
|
+
}
|
|
1352
|
+
catch (exc) {
|
|
1353
|
+
console.warn('[aun_core] 群事件验签异常:', exc);
|
|
1354
|
+
return false;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
// ── E2EE 自动加密 ────────────────────────────────
|
|
1358
|
+
/** 自动加密并发送 P2P 消息 */
|
|
1359
|
+
async _sendEncrypted(params) {
|
|
1360
|
+
const toAid = String(params.to ?? '');
|
|
1361
|
+
this._validateMessageRecipient(toAid);
|
|
1362
|
+
const payload = isJsonObject(params.payload) ? params.payload : null;
|
|
1363
|
+
const messageId = String(params.message_id ?? '') || _uuidV4();
|
|
1364
|
+
const timestamp = params.timestamp ?? Date.now();
|
|
1365
|
+
if (payload === null) {
|
|
1366
|
+
throw new ValidationError('message.send payload must be an object when encrypt=true');
|
|
1367
|
+
}
|
|
1368
|
+
const persistRequired = Boolean(params.persist_required || params.durable);
|
|
1369
|
+
// Lazy P2P sync:首次发送前自动拉取历史,避免重连后 seq 空洞
|
|
1370
|
+
if (!this._p2pSynced) {
|
|
1371
|
+
await this._lazySyncP2p();
|
|
1372
|
+
}
|
|
1373
|
+
const recipientPrekeys = await this._fetchPeerPrekeys(toAid);
|
|
1374
|
+
const selfSyncCopies = await this._buildSelfSyncCopies({
|
|
1375
|
+
logicalToAid: toAid,
|
|
1376
|
+
payload,
|
|
1377
|
+
messageId,
|
|
1378
|
+
timestamp,
|
|
1379
|
+
});
|
|
1380
|
+
if (recipientPrekeys.length <= 1 && selfSyncCopies.length === 0) {
|
|
1381
|
+
return await this._sendEncryptedSingle({
|
|
1382
|
+
toAid,
|
|
1383
|
+
payload,
|
|
1384
|
+
messageId,
|
|
1385
|
+
timestamp,
|
|
1386
|
+
prekey: recipientPrekeys[0],
|
|
1387
|
+
persistRequired,
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
const recipientCopies = await this._buildRecipientDeviceCopies({
|
|
1391
|
+
toAid,
|
|
1392
|
+
payload,
|
|
1393
|
+
messageId,
|
|
1394
|
+
timestamp,
|
|
1395
|
+
prekeys: recipientPrekeys,
|
|
1396
|
+
});
|
|
1397
|
+
const sendParams = {
|
|
1398
|
+
to: toAid,
|
|
1399
|
+
payload: {
|
|
1400
|
+
type: 'e2ee.multi_device',
|
|
1401
|
+
logical_message_id: messageId,
|
|
1402
|
+
recipient_copies: recipientCopies,
|
|
1403
|
+
self_copies: selfSyncCopies,
|
|
1404
|
+
},
|
|
1405
|
+
type: 'e2ee.multi_device',
|
|
1406
|
+
encrypted: true,
|
|
1407
|
+
message_id: messageId,
|
|
1408
|
+
timestamp,
|
|
1409
|
+
};
|
|
1410
|
+
if (persistRequired) {
|
|
1411
|
+
sendParams.persist_required = true;
|
|
1412
|
+
}
|
|
1413
|
+
return this._transport.call('message.send', sendParams);
|
|
1414
|
+
}
|
|
1415
|
+
/**
|
|
1416
|
+
* 首次发送 P2P 消息前懒拉取历史消息,同步 seqTracker 避免空洞。
|
|
1417
|
+
* 只在本连接周期内执行一次。
|
|
1418
|
+
*/
|
|
1419
|
+
async _lazySyncP2p() {
|
|
1420
|
+
this._p2pSynced = true;
|
|
1421
|
+
if (!this._aid)
|
|
1422
|
+
return;
|
|
1423
|
+
const ns = `p2p:${this._aid}`;
|
|
1424
|
+
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1425
|
+
try {
|
|
1426
|
+
const result = await this._transport.call('message.pull', {
|
|
1427
|
+
after_seq: afterSeq,
|
|
1428
|
+
limit: 200,
|
|
1429
|
+
});
|
|
1430
|
+
if (isJsonObject(result)) {
|
|
1431
|
+
const messages = result.messages;
|
|
1432
|
+
if (Array.isArray(messages) && messages.length > 0) {
|
|
1433
|
+
this._seqTracker.onPullResult(ns, messages.filter(isJsonObject));
|
|
1434
|
+
this._saveSeqTrackerState();
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
catch (exc) {
|
|
1439
|
+
console.warn('[aun_core] lazySyncP2p 失败:', exc);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
async _sendEncryptedSingle(opts) {
|
|
1443
|
+
let prekey = opts.prekey;
|
|
1444
|
+
if (prekey === undefined) {
|
|
1445
|
+
prekey = await this._fetchPeerPrekey(opts.toAid);
|
|
1446
|
+
}
|
|
1447
|
+
const peerCertFingerprint = typeof prekey?.cert_fingerprint === 'string' ? prekey.cert_fingerprint : undefined;
|
|
1448
|
+
const peerCertPem = await this._fetchPeerCert(opts.toAid, peerCertFingerprint);
|
|
1449
|
+
const [envelope, encryptResult] = await this._encryptCopyPayload({
|
|
1450
|
+
logicalToAid: opts.toAid,
|
|
1451
|
+
payload: opts.payload,
|
|
1452
|
+
peerCertPem,
|
|
1453
|
+
prekey,
|
|
1454
|
+
messageId: opts.messageId,
|
|
1455
|
+
timestamp: opts.timestamp,
|
|
1456
|
+
});
|
|
1457
|
+
await this._ensureEncryptResult(opts.toAid, encryptResult);
|
|
1458
|
+
const sendParams = {
|
|
1459
|
+
to: opts.toAid,
|
|
1460
|
+
payload: envelope,
|
|
1461
|
+
type: 'e2ee.encrypted',
|
|
1462
|
+
encrypted: true,
|
|
1463
|
+
message_id: opts.messageId,
|
|
1464
|
+
timestamp: opts.timestamp,
|
|
1465
|
+
};
|
|
1466
|
+
if (opts.persistRequired) {
|
|
1467
|
+
sendParams.persist_required = true;
|
|
1468
|
+
}
|
|
1469
|
+
return this._transport.call('message.send', sendParams);
|
|
1470
|
+
}
|
|
1471
|
+
async _buildRecipientDeviceCopies(opts) {
|
|
1472
|
+
const recipientCopies = [];
|
|
1473
|
+
const certCache = new Map();
|
|
1474
|
+
for (const prekey of opts.prekeys) {
|
|
1475
|
+
const deviceId = String(prekey.device_id ?? '').trim();
|
|
1476
|
+
const peerCertFingerprint = String(prekey.cert_fingerprint ?? '').trim().toLowerCase();
|
|
1477
|
+
const cacheKey = peerCertFingerprint || '__default__';
|
|
1478
|
+
let peerCertPem = certCache.get(cacheKey);
|
|
1479
|
+
if (!peerCertPem) {
|
|
1480
|
+
peerCertPem = await this._fetchPeerCert(opts.toAid, peerCertFingerprint || undefined);
|
|
1481
|
+
certCache.set(cacheKey, peerCertPem);
|
|
1482
|
+
}
|
|
1483
|
+
const [envelope, encryptResult] = await this._encryptCopyPayload({
|
|
1484
|
+
logicalToAid: opts.toAid,
|
|
1485
|
+
payload: opts.payload,
|
|
1486
|
+
peerCertPem,
|
|
1487
|
+
prekey,
|
|
1488
|
+
messageId: opts.messageId,
|
|
1489
|
+
timestamp: opts.timestamp,
|
|
1490
|
+
});
|
|
1491
|
+
await this._ensureEncryptResult(opts.toAid, encryptResult);
|
|
1492
|
+
recipientCopies.push({
|
|
1493
|
+
device_id: deviceId,
|
|
1494
|
+
envelope,
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
if (recipientCopies.length === 0) {
|
|
1498
|
+
throw new E2EEError(`no recipient device copies generated for ${opts.toAid}`);
|
|
1499
|
+
}
|
|
1500
|
+
return recipientCopies;
|
|
1501
|
+
}
|
|
1502
|
+
async _resolveSelfCopyPeerCert(certFingerprint) {
|
|
1503
|
+
const myAid = this._aid;
|
|
1504
|
+
if (!myAid) {
|
|
1505
|
+
throw new E2EEError('self sync copy requires current aid');
|
|
1506
|
+
}
|
|
1507
|
+
const normalized = String(certFingerprint ?? '').trim().toLowerCase();
|
|
1508
|
+
const identityCert = typeof this._identity?.cert === 'string' ? this._identity.cert : '';
|
|
1509
|
+
if (identityCert) {
|
|
1510
|
+
const actual = await this._certFingerprint(identityCert);
|
|
1511
|
+
if (!normalized || actual === normalized) {
|
|
1512
|
+
return identityCert;
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
const localCert = await this._keystore.loadCert(myAid, normalized || undefined);
|
|
1516
|
+
if (localCert) {
|
|
1517
|
+
if (!normalized) {
|
|
1518
|
+
return localCert;
|
|
1519
|
+
}
|
|
1520
|
+
const actualFingerprint = await this._certFingerprint(localCert);
|
|
1521
|
+
if (actualFingerprint === normalized) {
|
|
1522
|
+
return localCert;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
return await this._fetchPeerCert(myAid, normalized || undefined);
|
|
1526
|
+
}
|
|
1527
|
+
async _buildSelfSyncCopies(opts) {
|
|
1528
|
+
const myAid = this._aid;
|
|
1529
|
+
if (!myAid)
|
|
1530
|
+
return [];
|
|
1531
|
+
const prekeys = await this._fetchPeerPrekeys(myAid);
|
|
1532
|
+
if (prekeys.length === 0)
|
|
1533
|
+
return [];
|
|
1534
|
+
const copies = [];
|
|
1535
|
+
for (const prekey of prekeys) {
|
|
1536
|
+
const deviceId = String(prekey.device_id ?? '').trim();
|
|
1537
|
+
if (deviceId && deviceId === this._deviceId) {
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
const peerCertPem = await this._resolveSelfCopyPeerCert(String(prekey.cert_fingerprint ?? '').trim().toLowerCase() || undefined);
|
|
1541
|
+
const [envelope, encryptResult] = await this._encryptCopyPayload({
|
|
1542
|
+
logicalToAid: opts.logicalToAid,
|
|
1543
|
+
payload: opts.payload,
|
|
1544
|
+
peerCertPem,
|
|
1545
|
+
prekey,
|
|
1546
|
+
messageId: opts.messageId,
|
|
1547
|
+
timestamp: opts.timestamp,
|
|
1548
|
+
});
|
|
1549
|
+
await this._ensureEncryptResult(myAid, encryptResult);
|
|
1550
|
+
copies.push({
|
|
1551
|
+
device_id: deviceId,
|
|
1552
|
+
envelope,
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
return copies;
|
|
1556
|
+
}
|
|
1557
|
+
async _encryptCopyPayload(opts) {
|
|
1558
|
+
const [envelope, encryptResult] = await this._e2ee.encryptOutbound(opts.logicalToAid, opts.payload, {
|
|
1559
|
+
peerCertPem: opts.peerCertPem,
|
|
1560
|
+
prekey: opts.prekey ?? null,
|
|
1561
|
+
messageId: opts.messageId,
|
|
1562
|
+
timestamp: opts.timestamp,
|
|
1563
|
+
});
|
|
1564
|
+
return [envelope, encryptResult];
|
|
1565
|
+
}
|
|
1566
|
+
async _ensureEncryptResult(toAid, encryptResult) {
|
|
1567
|
+
if (!encryptResult.encrypted) {
|
|
1568
|
+
throw new E2EEError(`failed to encrypt message to ${toAid}`);
|
|
1569
|
+
}
|
|
1570
|
+
if (this.configModel.requireForwardSecrecy && !encryptResult.forward_secrecy) {
|
|
1571
|
+
throw new E2EEError(`forward secrecy required but unavailable for ${toAid} (mode=${encryptResult.mode})`);
|
|
1572
|
+
}
|
|
1573
|
+
if (encryptResult.degraded) {
|
|
1574
|
+
try {
|
|
1575
|
+
await this._dispatcher.publish('e2ee.degraded', {
|
|
1576
|
+
peer_aid: toAid,
|
|
1577
|
+
mode: encryptResult.mode,
|
|
1578
|
+
reason: encryptResult.degradation_reason,
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
catch (exc) {
|
|
1582
|
+
console.warn('发布 e2ee.degraded 事件失败:', exc);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
async _certFingerprint(certPem) {
|
|
1587
|
+
const certBytes = pemToArrayBuffer(certPem);
|
|
1588
|
+
const digest = await crypto.subtle.digest('SHA-256', certBytes);
|
|
1589
|
+
return 'sha256:' + Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
1590
|
+
}
|
|
1591
|
+
/**
|
|
1592
|
+
* 从 X.509 DER 证书中提取 SubjectPublicKeyInfo 并计算其 SHA-256 指纹。
|
|
1593
|
+
* 返回 "sha256:<hex>",提取失败返回空串。
|
|
1594
|
+
* 用于 H7 指纹校验(DER 证书指纹 OR SPKI 指纹任一匹配)。
|
|
1595
|
+
*/
|
|
1596
|
+
async _spkiFingerprint(certPem) {
|
|
1597
|
+
try {
|
|
1598
|
+
const der = new Uint8Array(pemToArrayBuffer(certPem));
|
|
1599
|
+
// X.509: Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signatureValue }
|
|
1600
|
+
// tbsCertificate ::= SEQUENCE { version?, serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo, ... }
|
|
1601
|
+
// 逐级解析 SEQUENCE 并定位 SPKI (第 7 个或第 6 个子元素,取决于 version 是否 [0] explicit)。
|
|
1602
|
+
const readLen = (buf, pos) => {
|
|
1603
|
+
const first = buf[pos];
|
|
1604
|
+
if (first < 0x80)
|
|
1605
|
+
return { len: first, next: pos + 1 };
|
|
1606
|
+
const n = first & 0x7f;
|
|
1607
|
+
let len = 0;
|
|
1608
|
+
for (let i = 0; i < n; i++)
|
|
1609
|
+
len = (len << 8) | buf[pos + 1 + i];
|
|
1610
|
+
return { len, next: pos + 1 + n };
|
|
1611
|
+
};
|
|
1612
|
+
// 外层 SEQUENCE
|
|
1613
|
+
if (der[0] !== 0x30)
|
|
1614
|
+
return '';
|
|
1615
|
+
const outer = readLen(der, 1);
|
|
1616
|
+
// tbsCertificate SEQUENCE 起点
|
|
1617
|
+
const tbsStart = outer.next;
|
|
1618
|
+
if (der[tbsStart] !== 0x30)
|
|
1619
|
+
return '';
|
|
1620
|
+
const tbsLen = readLen(der, tbsStart + 1);
|
|
1621
|
+
let p = tbsLen.next;
|
|
1622
|
+
const tbsEnd = tbsLen.next + tbsLen.len;
|
|
1623
|
+
// 跳过 [0] EXPLICIT Version(可选)
|
|
1624
|
+
if (der[p] === 0xa0) {
|
|
1625
|
+
const lv = readLen(der, p + 1);
|
|
1626
|
+
p = lv.next + lv.len;
|
|
1627
|
+
}
|
|
1628
|
+
// 跳过 serialNumber (INTEGER)
|
|
1629
|
+
if (der[p] !== 0x02)
|
|
1630
|
+
return '';
|
|
1631
|
+
let lv = readLen(der, p + 1);
|
|
1632
|
+
p = lv.next + lv.len;
|
|
1633
|
+
// 跳过 signature (SEQUENCE)
|
|
1634
|
+
if (der[p] !== 0x30)
|
|
1635
|
+
return '';
|
|
1636
|
+
lv = readLen(der, p + 1);
|
|
1637
|
+
p = lv.next + lv.len;
|
|
1638
|
+
// 跳过 issuer (SEQUENCE)
|
|
1639
|
+
if (der[p] !== 0x30)
|
|
1640
|
+
return '';
|
|
1641
|
+
lv = readLen(der, p + 1);
|
|
1642
|
+
p = lv.next + lv.len;
|
|
1643
|
+
// 跳过 validity (SEQUENCE)
|
|
1644
|
+
if (der[p] !== 0x30)
|
|
1645
|
+
return '';
|
|
1646
|
+
lv = readLen(der, p + 1);
|
|
1647
|
+
p = lv.next + lv.len;
|
|
1648
|
+
// 跳过 subject (SEQUENCE)
|
|
1649
|
+
if (der[p] !== 0x30)
|
|
1650
|
+
return '';
|
|
1651
|
+
lv = readLen(der, p + 1);
|
|
1652
|
+
p = lv.next + lv.len;
|
|
1653
|
+
// subjectPublicKeyInfo (SEQUENCE) — 连同 tag+length+value 全部即 SPKI DER
|
|
1654
|
+
if (der[p] !== 0x30 || p >= tbsEnd)
|
|
1655
|
+
return '';
|
|
1656
|
+
const spkiStart = p;
|
|
1657
|
+
const spkiLV = readLen(der, p + 1);
|
|
1658
|
+
const spkiEnd = spkiLV.next + spkiLV.len;
|
|
1659
|
+
const spkiDer = der.subarray(spkiStart, spkiEnd);
|
|
1660
|
+
const digest = await crypto.subtle.digest('SHA-256', spkiDer);
|
|
1661
|
+
return 'sha256:' + Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
1662
|
+
}
|
|
1663
|
+
catch {
|
|
1664
|
+
return '';
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
/** 自动加密并发送群组消息 */
|
|
1668
|
+
async _sendGroupEncrypted(params) {
|
|
1669
|
+
const groupId = String(params.group_id ?? '');
|
|
1670
|
+
const payload = isJsonObject(params.payload) ? params.payload : null;
|
|
1671
|
+
if (!groupId) {
|
|
1672
|
+
throw new ValidationError('group.send requires group_id');
|
|
1673
|
+
}
|
|
1674
|
+
if (payload === null) {
|
|
1675
|
+
throw new ValidationError('group.send payload must be an object when encrypt=true');
|
|
1676
|
+
}
|
|
1677
|
+
// Lazy group sync:首次发送群消息前自动拉取历史,避免重连后 seq 空洞
|
|
1678
|
+
if (!this._groupSynced.has(groupId)) {
|
|
1679
|
+
await this._lazySyncGroup(groupId);
|
|
1680
|
+
}
|
|
1681
|
+
await this._ensureGroupEpochReady(groupId, false);
|
|
1682
|
+
await this._waitForGroupMembershipEpochFloor(groupId, 2000);
|
|
1683
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1684
|
+
const epochResult = await this._committedGroupEpochState(groupId);
|
|
1685
|
+
const committedEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
1686
|
+
const envelope = committedEpoch > 0
|
|
1687
|
+
? await this._groupE2ee.encryptWithEpoch(groupId, await this._ensureCommittedGroupSecretForSend(groupId, committedEpoch, epochResult), payload)
|
|
1688
|
+
: await this._groupE2ee.encrypt(groupId, payload);
|
|
1689
|
+
const sendParams = {
|
|
1690
|
+
group_id: groupId,
|
|
1691
|
+
payload: envelope,
|
|
1692
|
+
type: 'e2ee.group_encrypted',
|
|
1693
|
+
encrypted: true,
|
|
1694
|
+
};
|
|
1695
|
+
if (this._deviceId && sendParams.device_id === undefined) {
|
|
1696
|
+
sendParams.device_id = this._deviceId;
|
|
1697
|
+
}
|
|
1698
|
+
await this._signClientOperation('group.send', sendParams);
|
|
1699
|
+
try {
|
|
1700
|
+
return await this._transport.call('group.send', sendParams);
|
|
1701
|
+
}
|
|
1702
|
+
catch (exc) {
|
|
1703
|
+
if (attempt === 0 && this._isRecoverableGroupEpochError(exc)) {
|
|
1704
|
+
console.warn(`[aun_core] 群 ${groupId} 发送时 epoch 已过旧,恢复密钥后重加密重发一次: ${formatCaughtError(exc)}`);
|
|
1705
|
+
await this._ensureGroupEpochReady(groupId, true);
|
|
1706
|
+
continue;
|
|
1707
|
+
}
|
|
1708
|
+
throw exc;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
throw new StateError(`group ${groupId} send failed after epoch recovery retry`);
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* 首次发送群消息前懒拉取历史消息,同步 seqTracker 避免空洞。
|
|
1715
|
+
* 只在本连接周期内执行一次(per groupId)。
|
|
1716
|
+
*/
|
|
1717
|
+
async _lazySyncGroup(groupId) {
|
|
1718
|
+
this._groupSynced.add(groupId);
|
|
1719
|
+
const ns = `group:${groupId}`;
|
|
1720
|
+
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1721
|
+
try {
|
|
1722
|
+
const result = await this._transport.call('group.pull', {
|
|
1723
|
+
group_id: groupId,
|
|
1724
|
+
after_message_seq: afterSeq,
|
|
1725
|
+
device_id: this._deviceId,
|
|
1726
|
+
limit: 200,
|
|
1727
|
+
});
|
|
1728
|
+
if (isJsonObject(result)) {
|
|
1729
|
+
const messages = result.messages;
|
|
1730
|
+
if (Array.isArray(messages) && messages.length > 0) {
|
|
1731
|
+
this._seqTracker.onPullResult(ns, messages.filter(isJsonObject));
|
|
1732
|
+
this._saveSeqTrackerState();
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
catch (exc) {
|
|
1737
|
+
console.warn(`[aun_core] lazySyncGroup(${groupId}) 失败:`, exc);
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
_isGroupEpochTooOldError(exc) {
|
|
1741
|
+
const text = String(exc).toLowerCase();
|
|
1742
|
+
return text.includes('e2ee epoch too old') || text.includes('epoch below sender membership floor');
|
|
1743
|
+
}
|
|
1744
|
+
_isGroupEpochRotationPendingError(exc) {
|
|
1745
|
+
const text = String(exc).toLowerCase();
|
|
1746
|
+
return text.includes('e2ee epoch rotation pending') || text.includes('e2ee epoch not committed');
|
|
1747
|
+
}
|
|
1748
|
+
_isGroupEpochChangedDuringSendError(exc) {
|
|
1749
|
+
return String(exc).toLowerCase().includes('e2ee epoch changed during send');
|
|
1750
|
+
}
|
|
1751
|
+
_isRecoverableGroupEpochError(exc) {
|
|
1752
|
+
return this._isGroupEpochTooOldError(exc)
|
|
1753
|
+
|| this._isGroupEpochRotationPendingError(exc)
|
|
1754
|
+
|| this._isGroupEpochChangedDuringSendError(exc);
|
|
1755
|
+
}
|
|
1756
|
+
async _groupKeyRecoveryCandidates(groupId, epochResult) {
|
|
1757
|
+
const candidates = [];
|
|
1758
|
+
const add = (value) => {
|
|
1759
|
+
if (typeof value !== 'string')
|
|
1760
|
+
return;
|
|
1761
|
+
const aid = value.trim();
|
|
1762
|
+
if (aid && aid !== this._aid && !candidates.includes(aid))
|
|
1763
|
+
candidates.push(aid);
|
|
1764
|
+
};
|
|
1765
|
+
add(epochResult.rotated_by);
|
|
1766
|
+
add(epochResult.owner_aid);
|
|
1767
|
+
for (const key of ['recovery_candidates', 'admins', 'members']) {
|
|
1768
|
+
const values = epochResult[key];
|
|
1769
|
+
if (Array.isArray(values)) {
|
|
1770
|
+
for (const value of values) {
|
|
1771
|
+
if (typeof value === 'string')
|
|
1772
|
+
add(value);
|
|
1773
|
+
else if (isJsonObject(value))
|
|
1774
|
+
add(value.aid);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
const localMembers = await this._groupE2ee.getMemberAids(groupId);
|
|
1779
|
+
for (const aid of localMembers)
|
|
1780
|
+
add(aid);
|
|
1781
|
+
return candidates;
|
|
1782
|
+
}
|
|
1783
|
+
async _requestGroupKeyFromCandidates(groupId, serverEpoch, epochResult) {
|
|
1784
|
+
for (const targetAid of await this._groupKeyRecoveryCandidates(groupId, epochResult)) {
|
|
1785
|
+
await this._requestGroupKeyFrom(groupId, targetAid, serverEpoch);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
async _recoverInitialGroupEpochIfNeeded(groupId, localEpoch, epochResult) {
|
|
1789
|
+
const serverEpoch = Number(epochResult.epoch ?? 0);
|
|
1790
|
+
if (serverEpoch !== 0 || localEpoch !== 1)
|
|
1791
|
+
return epochResult;
|
|
1792
|
+
const secretData = await this._groupE2ee.loadSecret(groupId, 1);
|
|
1793
|
+
if (!secretData || secretData.pending_rotation_id)
|
|
1794
|
+
return epochResult;
|
|
1795
|
+
console.warn(`[aun_core] 群 ${groupId} 检测到本地 epoch 1 已存在但服务端 epoch 仍为 0,尝试补同步初始 epoch`);
|
|
1796
|
+
await this._syncEpochToServer(groupId);
|
|
1797
|
+
try {
|
|
1798
|
+
const refreshed = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
1799
|
+
if (isJsonObject(refreshed))
|
|
1800
|
+
return refreshed;
|
|
1801
|
+
}
|
|
1802
|
+
catch (exc) {
|
|
1803
|
+
console.warn(`[aun_core] 群 ${groupId} 初始 epoch 补同步后刷新服务端 epoch 失败: ${formatCaughtError(exc)}`);
|
|
1804
|
+
}
|
|
1805
|
+
return epochResult;
|
|
1806
|
+
}
|
|
1807
|
+
async _ensureGroupEpochReady(groupId, strict) {
|
|
1808
|
+
const localEpoch = await this._groupE2ee.currentEpoch(groupId);
|
|
1809
|
+
const initialLocalEpoch = localEpoch ?? 0;
|
|
1810
|
+
let epochResult;
|
|
1811
|
+
try {
|
|
1812
|
+
const rawEpochResult = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
1813
|
+
if (!isJsonObject(rawEpochResult))
|
|
1814
|
+
return;
|
|
1815
|
+
epochResult = rawEpochResult;
|
|
1816
|
+
}
|
|
1817
|
+
catch (exc) {
|
|
1818
|
+
if (strict)
|
|
1819
|
+
throw new StateError(`group ${groupId} failed to query server epoch before retry: ${formatCaughtError(exc)}`);
|
|
1820
|
+
console.warn(`[aun_core] group ${groupId} epoch precheck failed: ${formatCaughtError(exc)}`);
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
let serverEpoch = Number(epochResult.epoch ?? 0);
|
|
1824
|
+
if (!Number.isFinite(serverEpoch))
|
|
1825
|
+
return;
|
|
1826
|
+
const pending = isJsonObject(epochResult.pending_rotation) ? epochResult.pending_rotation : null;
|
|
1827
|
+
if (pending && !pending.expired) {
|
|
1828
|
+
const pendingBaseEpoch = Number(pending.base_epoch ?? serverEpoch);
|
|
1829
|
+
this._scheduleGroupRotationRetry(groupId, {
|
|
1830
|
+
reason: 'pending_recovery',
|
|
1831
|
+
triggerId: String(pending.rotation_id ?? ''),
|
|
1832
|
+
expectedEpoch: Number.isFinite(pendingBaseEpoch) ? pendingBaseEpoch : serverEpoch,
|
|
1833
|
+
pending,
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
let effectiveLocalEpoch = initialLocalEpoch;
|
|
1837
|
+
if (serverEpoch === 0 && effectiveLocalEpoch === 1) {
|
|
1838
|
+
epochResult = await this._recoverInitialGroupEpochIfNeeded(groupId, effectiveLocalEpoch, epochResult);
|
|
1839
|
+
serverEpoch = Number(epochResult.epoch ?? 0);
|
|
1840
|
+
if (serverEpoch === 0) {
|
|
1841
|
+
throw new StateError(`group ${groupId} initial epoch sync has not completed; refuse to send with local epoch 1 while server epoch is 0`);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
if (serverEpoch <= effectiveLocalEpoch) {
|
|
1845
|
+
if (!strict || serverEpoch <= 0)
|
|
1846
|
+
return;
|
|
1847
|
+
const waitDeadline = Date.now() + 5000;
|
|
1848
|
+
while (Date.now() < waitDeadline) {
|
|
1849
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
1850
|
+
const refreshed = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
1851
|
+
const refreshedEpoch = isJsonObject(refreshed) ? Number(refreshed.epoch ?? 0) : 0;
|
|
1852
|
+
const currentLocal = await this._groupE2ee.currentEpoch(groupId);
|
|
1853
|
+
if (Number.isFinite(refreshedEpoch) && refreshedEpoch > serverEpoch) {
|
|
1854
|
+
epochResult = refreshed;
|
|
1855
|
+
serverEpoch = refreshedEpoch;
|
|
1856
|
+
effectiveLocalEpoch = currentLocal ?? 0;
|
|
1857
|
+
break;
|
|
1858
|
+
}
|
|
1859
|
+
if (currentLocal !== null && currentLocal > effectiveLocalEpoch)
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
if (serverEpoch <= effectiveLocalEpoch) {
|
|
1863
|
+
throw new StateError(`group ${groupId} epoch rotation has not completed`);
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
console.warn(`[aun_core] group ${groupId} local epoch=${effectiveLocalEpoch} < server epoch=${serverEpoch}; requesting key recovery`);
|
|
1867
|
+
await this._requestGroupKeyFromCandidates(groupId, serverEpoch, epochResult);
|
|
1868
|
+
const deadline = Date.now() + 5000;
|
|
1869
|
+
while (Date.now() < deadline) {
|
|
1870
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
1871
|
+
const refreshedEpoch = await this._groupE2ee.currentEpoch(groupId);
|
|
1872
|
+
if (refreshedEpoch !== null && refreshedEpoch >= serverEpoch)
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
const refreshedEpoch = await this._groupE2ee.currentEpoch(groupId);
|
|
1876
|
+
throw new StateError(`group ${groupId} local epoch ${refreshedEpoch} is behind server epoch ${serverEpoch}; key recovery has not completed`);
|
|
1877
|
+
}
|
|
1878
|
+
async _waitForGroupMembershipEpochFloor(groupId, timeoutMs) {
|
|
1879
|
+
void timeoutMs;
|
|
1880
|
+
while (true) {
|
|
1881
|
+
let committedEpoch = 0;
|
|
1882
|
+
let members = null;
|
|
1883
|
+
try {
|
|
1884
|
+
const epochResult = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
1885
|
+
committedEpoch = isJsonObject(epochResult) ? Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0) : 0;
|
|
1886
|
+
const membersResult = await this.call('group.get_members', { group_id: groupId });
|
|
1887
|
+
members = isJsonObject(membersResult) ? membersResult.members : null;
|
|
1888
|
+
}
|
|
1889
|
+
catch (exc) {
|
|
1890
|
+
console.warn(`[aun_core] 群 ${groupId} 成员 epoch floor 预检跳过: ${formatCaughtError(exc)}`);
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
let maxMinReadEpoch = 0;
|
|
1894
|
+
if (Array.isArray(members)) {
|
|
1895
|
+
for (const member of members) {
|
|
1896
|
+
if (!isJsonObject(member))
|
|
1897
|
+
continue;
|
|
1898
|
+
const value = Number(member.min_read_epoch ?? 0);
|
|
1899
|
+
if (Number.isFinite(value))
|
|
1900
|
+
maxMinReadEpoch = Math.max(maxMinReadEpoch, value);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
if (maxMinReadEpoch <= committedEpoch)
|
|
1904
|
+
return;
|
|
1905
|
+
console.warn(`[aun_core] 群 ${groupId} 成员 min_read_epoch 高于 committed epoch,按 committed epoch 继续发送: committed=${committedEpoch} floor=${maxMinReadEpoch}`);
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
async _committedGroupEpochState(groupId) {
|
|
1910
|
+
try {
|
|
1911
|
+
const epochResult = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
1912
|
+
if (isJsonObject(epochResult))
|
|
1913
|
+
return epochResult;
|
|
1914
|
+
}
|
|
1915
|
+
catch (exc) {
|
|
1916
|
+
console.warn(`[aun_core] 群 ${groupId} 查询 committed epoch 状态失败,回退本地 epoch: ${formatCaughtError(exc)}`);
|
|
1917
|
+
}
|
|
1918
|
+
const localEpoch = await this._groupE2ee.currentEpoch(groupId);
|
|
1919
|
+
return { epoch: localEpoch ?? 0, committed_epoch: localEpoch ?? 0 };
|
|
1920
|
+
}
|
|
1921
|
+
_groupSecretMatchesCommittedRotation(secretData, committedRotation) {
|
|
1922
|
+
if (!secretData)
|
|
1923
|
+
return false;
|
|
1924
|
+
const committedCommitment = committedRotation ? String(committedRotation.key_commitment ?? '').trim() : '';
|
|
1925
|
+
const localCommitment = String(secretData.commitment ?? '').trim();
|
|
1926
|
+
if (committedCommitment && committedCommitment !== localCommitment)
|
|
1927
|
+
return false;
|
|
1928
|
+
const pendingRotationId = String(secretData.pending_rotation_id ?? '').trim();
|
|
1929
|
+
if (!pendingRotationId)
|
|
1930
|
+
return true;
|
|
1931
|
+
if (!committedRotation)
|
|
1932
|
+
return false;
|
|
1933
|
+
if (String(committedRotation.rotation_id ?? '').trim() !== pendingRotationId)
|
|
1934
|
+
return false;
|
|
1935
|
+
return true;
|
|
1936
|
+
}
|
|
1937
|
+
async _ensureCommittedGroupSecretForSend(groupId, committedEpoch, epochResult) {
|
|
1938
|
+
if (committedEpoch <= 0)
|
|
1939
|
+
return committedEpoch;
|
|
1940
|
+
const secretData = await this._groupE2ee.loadSecret(groupId, committedEpoch);
|
|
1941
|
+
const committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
|
|
1942
|
+
if (this._groupSecretMatchesCommittedRotation(secretData, committedRotation)) {
|
|
1943
|
+
return committedEpoch;
|
|
1944
|
+
}
|
|
1945
|
+
const pendingRotationId = secretData ? String(secretData.pending_rotation_id ?? '') : '';
|
|
1946
|
+
console.warn(`[aun_core] 群 ${groupId} epoch ${committedEpoch} 本地 pending key 未匹配服务端 committed rotation,先恢复密钥: local_rotation=${pendingRotationId || '-'}`);
|
|
1947
|
+
await this._recoverGroupEpochKey(groupId, committedEpoch, '', 5000);
|
|
1948
|
+
let refreshed = await this._committedGroupEpochState(groupId);
|
|
1949
|
+
const refreshedCommittedEpoch = Number(refreshed.committed_epoch ?? refreshed.epoch ?? committedEpoch);
|
|
1950
|
+
if (Number.isFinite(refreshedCommittedEpoch) && refreshedCommittedEpoch > committedEpoch) {
|
|
1951
|
+
committedEpoch = refreshedCommittedEpoch;
|
|
1952
|
+
await this._recoverGroupEpochKey(groupId, committedEpoch, '', 5000);
|
|
1953
|
+
refreshed = await this._committedGroupEpochState(groupId);
|
|
1954
|
+
}
|
|
1955
|
+
const refreshedRotation = isJsonObject(refreshed.committed_rotation) ? refreshed.committed_rotation : null;
|
|
1956
|
+
const refreshedSecret = await this._groupE2ee.loadSecret(groupId, committedEpoch);
|
|
1957
|
+
if (!this._groupSecretMatchesCommittedRotation(refreshedSecret, refreshedRotation)) {
|
|
1958
|
+
throw new StateError(`group ${groupId} epoch ${committedEpoch} local key is pending or mismatched; refuse to send with uncommitted group key`);
|
|
1959
|
+
}
|
|
1960
|
+
return committedEpoch;
|
|
1961
|
+
}
|
|
1962
|
+
// ── E2EE 自动解密 ────────────────────────────────
|
|
1963
|
+
/** 解密单条 P2P 消息 */
|
|
1964
|
+
async _decryptSingleMessage(message) {
|
|
1965
|
+
const payload = isJsonObject(message.payload) ? message.payload : null;
|
|
1966
|
+
if (payload === null)
|
|
1967
|
+
return message;
|
|
1968
|
+
if (payload.type !== 'e2ee.encrypted')
|
|
1969
|
+
return message;
|
|
1970
|
+
if (message.encrypted === false)
|
|
1971
|
+
return message;
|
|
1972
|
+
// 确保发送方证书已缓存(签名验证需要)
|
|
1973
|
+
const fromAid = (message.from ?? '');
|
|
1974
|
+
const senderCertFingerprint = String(payload.sender_cert_fingerprint ?? payload.aad?.sender_cert_fingerprint ?? '').trim().toLowerCase();
|
|
1975
|
+
if (fromAid) {
|
|
1976
|
+
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
1977
|
+
if (!certReady) {
|
|
1978
|
+
console.warn(`无法获取发送方 ${fromAid} 的证书,跳过解密`);
|
|
1979
|
+
throw new Error(`发送方证书不可用: from=${fromAid}, mid=${message.message_id}`);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
// 密码学解密(E2EEManager.decryptMessage 内含本地防重放)
|
|
1983
|
+
const decrypted = await this._e2ee.decryptMessage(message);
|
|
1984
|
+
this._schedulePrekeyReplenishIfConsumed(decrypted);
|
|
1985
|
+
if (decrypted === null) {
|
|
1986
|
+
throw new Error(`E2EE 解密失败: from=${message.from}, mid=${message.message_id}`);
|
|
1987
|
+
}
|
|
1988
|
+
return decrypted;
|
|
1989
|
+
}
|
|
1990
|
+
/** 批量解密 P2P 消息(用于 message.pull) */
|
|
1991
|
+
async _decryptMessages(messages) {
|
|
1992
|
+
const seenInBatch = new Set();
|
|
1993
|
+
const result = [];
|
|
1994
|
+
for (const msg of messages) {
|
|
1995
|
+
const mid = (msg.message_id ?? '');
|
|
1996
|
+
if (mid && seenInBatch.has(mid))
|
|
1997
|
+
continue;
|
|
1998
|
+
if (mid)
|
|
1999
|
+
seenInBatch.add(mid);
|
|
2000
|
+
const payload = isJsonObject(msg.payload) ? msg.payload : null;
|
|
2001
|
+
if (payload !== null && await this._tryHandleGroupKeyMessage(msg)) {
|
|
2002
|
+
continue;
|
|
2003
|
+
}
|
|
2004
|
+
if (payload !== null
|
|
2005
|
+
&& payload.type === 'e2ee.encrypted'
|
|
2006
|
+
&& (msg.encrypted === true || !('encrypted' in msg))) {
|
|
2007
|
+
const fromAid = (msg.from ?? '');
|
|
2008
|
+
const senderCertFingerprint = String(payload.sender_cert_fingerprint ?? payload.aad?.sender_cert_fingerprint ?? '').trim().toLowerCase();
|
|
2009
|
+
if (fromAid) {
|
|
2010
|
+
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
2011
|
+
if (!certReady) {
|
|
2012
|
+
console.warn(`无法获取发送方 ${fromAid} 的证书,跳过解密`);
|
|
2013
|
+
continue;
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
// Pull 场景:跳过防重放和 timestamp 窗口检查(push 已处理过的消息仍需要能解密)
|
|
2017
|
+
const decrypted = await this._e2ee.decryptMessage(msg, { skipReplay: true });
|
|
2018
|
+
if (decrypted !== null) {
|
|
2019
|
+
result.push(decrypted);
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
else {
|
|
2023
|
+
result.push(msg);
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
return result;
|
|
2027
|
+
}
|
|
2028
|
+
/** 解密单条群组消息。opts.skipReplay 用于 pull 场景跳过防重放。 */
|
|
2029
|
+
_enqueuePendingDecrypt(groupId, msg) {
|
|
2030
|
+
const ns = `group:${groupId}`;
|
|
2031
|
+
const queue = this._pendingDecryptMsgs.get(ns) ?? [];
|
|
2032
|
+
queue.push(msg);
|
|
2033
|
+
this._pendingDecryptMsgs.set(ns, queue.slice(-200));
|
|
2034
|
+
}
|
|
2035
|
+
async _retryPendingDecryptMsgs(groupId) {
|
|
2036
|
+
const ns = `group:${groupId}`;
|
|
2037
|
+
const queue = this._pendingDecryptMsgs.get(ns);
|
|
2038
|
+
if (!queue || queue.length === 0)
|
|
2039
|
+
return;
|
|
2040
|
+
this._pendingDecryptMsgs.set(ns, []);
|
|
2041
|
+
const stillPending = [];
|
|
2042
|
+
for (const msg of queue) {
|
|
2043
|
+
try {
|
|
2044
|
+
const decrypted = await this._decryptGroupMessage(msg);
|
|
2045
|
+
const payload = isJsonObject(msg.payload) ? msg.payload : null;
|
|
2046
|
+
if (payload?.type === 'e2ee.group_encrypted' && !decrypted.e2ee) {
|
|
2047
|
+
stillPending.push(msg);
|
|
2048
|
+
continue;
|
|
2049
|
+
}
|
|
2050
|
+
await this._dispatcher.publish('group.message_created', decrypted);
|
|
2051
|
+
}
|
|
2052
|
+
catch {
|
|
2053
|
+
stillPending.push(msg);
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
const queuedDuringRetry = this._pendingDecryptMsgs.get(ns) ?? [];
|
|
2057
|
+
const mergedPending = [...stillPending, ...queuedDuringRetry].slice(-200);
|
|
2058
|
+
if (mergedPending.length)
|
|
2059
|
+
this._pendingDecryptMsgs.set(ns, mergedPending);
|
|
2060
|
+
else
|
|
2061
|
+
this._pendingDecryptMsgs.delete(ns);
|
|
2062
|
+
}
|
|
2063
|
+
_scheduleRetryPendingDecryptMsgs(groupId) {
|
|
2064
|
+
if (!groupId || !this._pendingDecryptMsgs.has(`group:${groupId}`))
|
|
2065
|
+
return;
|
|
2066
|
+
this._safeAsync(this._retryPendingDecryptMsgs(groupId));
|
|
2067
|
+
}
|
|
2068
|
+
async _recoverGroupEpochKey(groupId, epoch, senderAid = '', timeoutMs = 5000) {
|
|
2069
|
+
const existing = await this._groupE2ee.loadSecret(groupId, epoch);
|
|
2070
|
+
if (await this._groupEpochSecretReadyForRecovery(groupId, epoch, existing)) {
|
|
2071
|
+
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
2072
|
+
return true;
|
|
2073
|
+
}
|
|
2074
|
+
// inflight 去重:同 groupId:epoch 的并发恢复共享同一个 Promise
|
|
2075
|
+
const key = `${groupId}:${epoch}`;
|
|
2076
|
+
const inflight = this._groupEpochRecoveryInflight.get(key);
|
|
2077
|
+
if (inflight)
|
|
2078
|
+
return inflight;
|
|
2079
|
+
const promise = this._doRecoverGroupEpochKey(groupId, epoch, senderAid, timeoutMs)
|
|
2080
|
+
.finally(() => this._groupEpochRecoveryInflight.delete(key));
|
|
2081
|
+
this._groupEpochRecoveryInflight.set(key, promise);
|
|
2082
|
+
return promise;
|
|
2083
|
+
}
|
|
2084
|
+
async _doRecoverGroupEpochKey(groupId, epoch, senderAid, timeoutMs) {
|
|
2085
|
+
let epochResult = { epoch };
|
|
2086
|
+
try {
|
|
2087
|
+
const raw = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
2088
|
+
if (isJsonObject(raw))
|
|
2089
|
+
epochResult = { ...epochResult, ...raw };
|
|
2090
|
+
}
|
|
2091
|
+
catch {
|
|
2092
|
+
// 候选查询失败时仍使用 sender/local members 兜底。
|
|
2093
|
+
}
|
|
2094
|
+
if (senderAid) {
|
|
2095
|
+
const current = Array.isArray(epochResult.recovery_candidates) ? epochResult.recovery_candidates : [];
|
|
2096
|
+
epochResult.recovery_candidates = [senderAid, ...current];
|
|
2097
|
+
}
|
|
2098
|
+
await this._requestGroupKeyFromCandidates(groupId, epoch, epochResult);
|
|
2099
|
+
const deadline = Date.now() + timeoutMs;
|
|
2100
|
+
while (Date.now() < deadline) {
|
|
2101
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
2102
|
+
const secret = await this._groupE2ee.loadSecret(groupId, epoch);
|
|
2103
|
+
if (await this._groupEpochSecretReadyForRecovery(groupId, epoch, secret)) {
|
|
2104
|
+
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
2105
|
+
return true;
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
const secret = await this._groupE2ee.loadSecret(groupId, epoch);
|
|
2109
|
+
const ready = await this._groupEpochSecretReadyForRecovery(groupId, epoch, secret);
|
|
2110
|
+
if (ready)
|
|
2111
|
+
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
2112
|
+
return ready;
|
|
2113
|
+
}
|
|
2114
|
+
async _groupEpochSecretReadyForRecovery(groupId, epoch, secret) {
|
|
2115
|
+
if (!isJsonObject(secret))
|
|
2116
|
+
return false;
|
|
2117
|
+
const pendingRotationId = String(secret.pending_rotation_id ?? '').trim();
|
|
2118
|
+
if (!pendingRotationId)
|
|
2119
|
+
return true;
|
|
2120
|
+
return this._pendingGroupSecretStillCurrent(groupId, epoch, pendingRotationId);
|
|
2121
|
+
}
|
|
2122
|
+
async _pendingGroupSecretStillCurrent(groupId, epoch, pendingRotationId) {
|
|
2123
|
+
try {
|
|
2124
|
+
const epochResult = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
2125
|
+
if (!isJsonObject(epochResult))
|
|
2126
|
+
return false;
|
|
2127
|
+
const pending = isJsonObject(epochResult.pending_rotation) ? epochResult.pending_rotation : null;
|
|
2128
|
+
if (pending
|
|
2129
|
+
&& !pending.expired
|
|
2130
|
+
&& String(pending.rotation_id ?? '').trim() === pendingRotationId) {
|
|
2131
|
+
return true;
|
|
2132
|
+
}
|
|
2133
|
+
const committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
|
|
2134
|
+
const committedTargetEpoch = Number(committedRotation?.target_epoch ?? 0);
|
|
2135
|
+
return Boolean(committedRotation
|
|
2136
|
+
&& Number.isFinite(committedTargetEpoch)
|
|
2137
|
+
&& committedTargetEpoch === epoch
|
|
2138
|
+
&& String(committedRotation.rotation_id ?? '').trim() === pendingRotationId);
|
|
2139
|
+
}
|
|
2140
|
+
catch {
|
|
2141
|
+
return false;
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
async _decryptGroupMessage(message, opts) {
|
|
2145
|
+
const payload = isJsonObject(message.payload) ? message.payload : null;
|
|
2146
|
+
if (payload === null || payload.type !== 'e2ee.group_encrypted') {
|
|
2147
|
+
return message;
|
|
2148
|
+
}
|
|
2149
|
+
// 确保发送方证书已缓存(签名验证需要)
|
|
2150
|
+
const senderAid = String(message.from ?? message.sender_aid ?? '');
|
|
2151
|
+
if (senderAid) {
|
|
2152
|
+
const certOk = await this._ensureSenderCertCached(senderAid);
|
|
2153
|
+
if (!certOk) {
|
|
2154
|
+
console.warn(`群消息解密跳过:发送方 ${senderAid} 证书不可用`);
|
|
2155
|
+
return message;
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
// 先尝试直接解密
|
|
2159
|
+
const result = await this._groupE2ee.decrypt(message, opts);
|
|
2160
|
+
if (result !== null && isJsonObject(result.e2ee)) {
|
|
2161
|
+
return result;
|
|
2162
|
+
}
|
|
2163
|
+
// replay guard 命中:decrypt 返回了原消息(非 null)但无 e2ee 字段
|
|
2164
|
+
// 不是解密失败,不应触发 recover
|
|
2165
|
+
if (result !== null) {
|
|
2166
|
+
return result;
|
|
2167
|
+
}
|
|
2168
|
+
// 真正的解密失败(result === null),尝试密钥恢复后重试
|
|
2169
|
+
const groupId = String(message.group_id ?? '');
|
|
2170
|
+
const sender = String(message.from ?? message.sender_aid ?? '');
|
|
2171
|
+
const epoch = Number(payload.epoch ?? 0);
|
|
2172
|
+
if (epoch > 0 && groupId) {
|
|
2173
|
+
try {
|
|
2174
|
+
if (await this._recoverGroupEpochKey(groupId, epoch, sender, 5000)) {
|
|
2175
|
+
const retry = await this._groupE2ee.decrypt(message, opts);
|
|
2176
|
+
if (retry !== null && retry.e2ee)
|
|
2177
|
+
return retry;
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
catch (exc) {
|
|
2181
|
+
console.debug(`[aun_core] 群 ${groupId} epoch ${epoch} 同步恢复失败: ${formatCaughtError(exc)}`);
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
return message;
|
|
2185
|
+
}
|
|
2186
|
+
/** 批量解密群组消息(用于 group.pull)。跳过防重放检查。 */
|
|
2187
|
+
async _decryptGroupMessages(messages) {
|
|
2188
|
+
const result = [];
|
|
2189
|
+
for (const msg of messages) {
|
|
2190
|
+
const decrypted = await this._decryptGroupMessage(msg);
|
|
2191
|
+
const payload = isJsonObject(msg.payload) ? msg.payload : null;
|
|
2192
|
+
const groupId = String(msg.group_id ?? '');
|
|
2193
|
+
if (payload?.type === 'e2ee.group_encrypted' && groupId && !decrypted.e2ee) {
|
|
2194
|
+
this._enqueuePendingDecrypt(groupId, msg);
|
|
2195
|
+
continue; // R3: 解密失败不入 result,不 publish 密文给应用层
|
|
2196
|
+
}
|
|
2197
|
+
result.push(decrypted);
|
|
2198
|
+
}
|
|
2199
|
+
return result;
|
|
2200
|
+
}
|
|
2201
|
+
/**
|
|
2202
|
+
* 尝试处理 P2P 传输的群组密钥消息。
|
|
2203
|
+
*
|
|
2204
|
+
* 返回值:
|
|
2205
|
+
* - true = 已处理 / 已被抑制(外层不应再 publish 到业务事件)
|
|
2206
|
+
* - false = 非密钥消息,外层应继续走普通 P2P 消息链路
|
|
2207
|
+
*
|
|
2208
|
+
* 外层 payload.type === 'e2ee.encrypted' 需要先解密才能判定是否为控制面。
|
|
2209
|
+
* 解密失败交给普通链路处理;普通链路只发布安全的 undecryptable 事件。
|
|
2210
|
+
*/
|
|
2211
|
+
async _tryHandleGroupKeyMessage(message) {
|
|
2212
|
+
let actualPayload = isJsonObject(message.payload) ? message.payload : null;
|
|
2213
|
+
if (actualPayload === null)
|
|
2214
|
+
return false;
|
|
2215
|
+
// 先解密 P2P E2EE 外壳
|
|
2216
|
+
if (actualPayload.type === 'e2ee.encrypted') {
|
|
2217
|
+
const fromAid = (message.from ?? '');
|
|
2218
|
+
const senderCertFingerprint = String(actualPayload.sender_cert_fingerprint ?? actualPayload.aad?.sender_cert_fingerprint ?? '').trim().toLowerCase();
|
|
2219
|
+
if (fromAid) {
|
|
2220
|
+
await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
2221
|
+
}
|
|
2222
|
+
let decrypted = null;
|
|
2223
|
+
try {
|
|
2224
|
+
decrypted = await this._e2ee.decryptMessage(message, { skipReplay: true });
|
|
2225
|
+
}
|
|
2226
|
+
catch (exc) {
|
|
2227
|
+
console.warn('[aun_core] e2ee.encrypted 外壳解密抛异常,交给普通链路处理:', exc);
|
|
2228
|
+
return false;
|
|
2229
|
+
}
|
|
2230
|
+
if (!decrypted) {
|
|
2231
|
+
return false;
|
|
2232
|
+
}
|
|
2233
|
+
this._schedulePrekeyReplenishIfConsumed(decrypted);
|
|
2234
|
+
actualPayload = isJsonObject(decrypted.payload) ? decrypted.payload : null;
|
|
2235
|
+
if (actualPayload === null)
|
|
2236
|
+
return false;
|
|
2237
|
+
}
|
|
2238
|
+
// S14: 按 envelope_type 识别控制面消息。内层 type 以 'e2ee.group_key_' 开头的
|
|
2239
|
+
// 一律视为群组密钥控制面,无论 handleIncoming 成功还是被拒绝,都不应
|
|
2240
|
+
// 向业务层投递。
|
|
2241
|
+
const innerType = String(actualPayload.type ?? '');
|
|
2242
|
+
const isGroupKeyCtrl = innerType.startsWith('e2ee.group_key_');
|
|
2243
|
+
let result = null;
|
|
2244
|
+
try {
|
|
2245
|
+
if (actualPayload.type === 'e2ee.group_key_distribution') {
|
|
2246
|
+
if (!await this._verifyActiveGroupRotationDistribution(actualPayload)) {
|
|
2247
|
+
return true;
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
else if (actualPayload.type === 'e2ee.group_key_response') {
|
|
2251
|
+
if (!await this._verifyGroupKeyResponseEpoch(actualPayload)) {
|
|
2252
|
+
return true;
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
result = await this._groupE2ee.handleIncoming(actualPayload);
|
|
2256
|
+
if (result === 'distribution') {
|
|
2257
|
+
await this._discardGroupDistributionIfStale(actualPayload);
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
catch (exc) {
|
|
2261
|
+
console.warn('[aun_core] 群组密钥消息处理异常:', exc);
|
|
2262
|
+
// S14: 控制面消息处理异常也要抑制业务分发
|
|
2263
|
+
if (isGroupKeyCtrl)
|
|
2264
|
+
return true;
|
|
2265
|
+
return false;
|
|
2266
|
+
}
|
|
2267
|
+
if (result === null) {
|
|
2268
|
+
// 非密钥消息。但若 envelope_type 显示为控制面(防御性:handleIncoming
|
|
2269
|
+
// 未识别但类型前缀匹配),仍抑制业务分发。
|
|
2270
|
+
return isGroupKeyCtrl;
|
|
2271
|
+
}
|
|
2272
|
+
if (result === 'request') {
|
|
2273
|
+
// 处理密钥请求并回复
|
|
2274
|
+
const groupId = (actualPayload.group_id ?? '');
|
|
2275
|
+
const requester = (actualPayload.requester_aid ?? '');
|
|
2276
|
+
let members = await this._groupE2ee.getMemberAids(groupId);
|
|
2277
|
+
// 请求者不在本地成员列表时,回源查询服务端最新成员列表
|
|
2278
|
+
if (requester && !members.includes(requester)) {
|
|
2279
|
+
try {
|
|
2280
|
+
const membersResult = await this.call('group.get_members', { group_id: groupId });
|
|
2281
|
+
const memberList = isJsonObject(membersResult) && Array.isArray(membersResult.members)
|
|
2282
|
+
? membersResult.members
|
|
2283
|
+
: [];
|
|
2284
|
+
members = memberList.map(m => String(m.aid ?? ''));
|
|
2285
|
+
// 更新本地当前 epoch 的 member_aids/commitment
|
|
2286
|
+
if (members.includes(requester)) {
|
|
2287
|
+
const secretData = await this._groupE2ee.loadSecret(groupId);
|
|
2288
|
+
if (secretData && this._aid) {
|
|
2289
|
+
const epoch = secretData.epoch;
|
|
2290
|
+
const commitment = await computeMembershipCommitment(members, epoch, groupId, secretData.secret);
|
|
2291
|
+
await storeGroupSecret(this._keystore, this._aid, groupId, epoch, secretData.secret, commitment, members);
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
catch (exc) {
|
|
2296
|
+
console.warn(`群组 ${groupId} 成员列表回源失败:`, exc);
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
const response = await this._groupE2ee.handleKeyRequestMsg(actualPayload, members);
|
|
2300
|
+
if (response && requester) {
|
|
2301
|
+
try {
|
|
2302
|
+
await this.call('message.send', {
|
|
2303
|
+
to: requester,
|
|
2304
|
+
payload: response,
|
|
2305
|
+
encrypt: true,
|
|
2306
|
+
persist_required: true,
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2309
|
+
catch (exc) {
|
|
2310
|
+
console.warn(`向 ${requester} 回复群组密钥失败:`, exc);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
// R4: 收到 distribution/response 后触发 pending 消息重试
|
|
2315
|
+
if (result === 'distribution' || result === 'response') {
|
|
2316
|
+
const groupId = String(actualPayload.group_id ?? '');
|
|
2317
|
+
const rotationId = String(actualPayload.rotation_id ?? '');
|
|
2318
|
+
const keyCommitment = String(actualPayload.commitment ?? '');
|
|
2319
|
+
if (rotationId && keyCommitment) {
|
|
2320
|
+
this._safeAsync(this._ackGroupRotationKey(rotationId, keyCommitment).then(() => undefined));
|
|
2321
|
+
}
|
|
2322
|
+
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
2323
|
+
}
|
|
2324
|
+
return true;
|
|
2325
|
+
}
|
|
2326
|
+
// ── E2EE 编排:证书与 prekey ──────────────────────
|
|
2327
|
+
/**
|
|
2328
|
+
* 获取对方证书(带缓存 + 完整 PKI 验证:链 + CRL + OCSP + AID 绑定)。
|
|
2329
|
+
* 跨域时自动将请求路由到 peer 所在域的 Gateway。
|
|
2330
|
+
*/
|
|
2331
|
+
async _fetchPeerCert(aid, certFingerprint) {
|
|
2332
|
+
const cacheKey = certCacheKey(aid, certFingerprint);
|
|
2333
|
+
const cached = this._certCache.get(cacheKey);
|
|
2334
|
+
const now = Date.now() / 1000;
|
|
2335
|
+
if (cached && now < cached.refreshAfter) {
|
|
2336
|
+
return cached.certPem;
|
|
2337
|
+
}
|
|
2338
|
+
const gatewayUrl = this._gatewayUrl;
|
|
2339
|
+
if (!gatewayUrl) {
|
|
2340
|
+
throw new ValidationError('gateway url unavailable for e2ee cert fetch');
|
|
2341
|
+
}
|
|
2342
|
+
// 跨域时用 peer 所在域的 Gateway URL
|
|
2343
|
+
const peerGatewayUrl = resolvePeerGatewayUrl(gatewayUrl, aid);
|
|
2344
|
+
let certPem;
|
|
2345
|
+
try {
|
|
2346
|
+
const certUrl = buildCertUrl(peerGatewayUrl, aid, certFingerprint);
|
|
2347
|
+
// 兼容旧浏览器,不使用 AbortSignal.timeout(Chrome 103+ 才支持)
|
|
2348
|
+
const controller = new AbortController();
|
|
2349
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
2350
|
+
try {
|
|
2351
|
+
const resp = await fetch(certUrl, { signal: controller.signal });
|
|
2352
|
+
if (!resp.ok)
|
|
2353
|
+
throw new ValidationError(`failed to fetch peer cert for ${aid}: HTTP ${resp.status}`);
|
|
2354
|
+
certPem = await resp.text();
|
|
2355
|
+
}
|
|
2356
|
+
finally {
|
|
2357
|
+
clearTimeout(timeoutId);
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
catch (exc) {
|
|
2361
|
+
if (!certFingerprint) {
|
|
2362
|
+
throw exc;
|
|
2363
|
+
}
|
|
2364
|
+
// 兼容旧浏览器,不使用 AbortSignal.timeout(Chrome 103+ 才支持)
|
|
2365
|
+
const fallbackController = new AbortController();
|
|
2366
|
+
const fallbackTimeoutId = setTimeout(() => fallbackController.abort(), 5000);
|
|
2367
|
+
try {
|
|
2368
|
+
const fallbackResp = await fetch(buildCertUrl(peerGatewayUrl, aid), { signal: fallbackController.signal });
|
|
2369
|
+
if (!fallbackResp.ok) {
|
|
2370
|
+
throw exc;
|
|
2371
|
+
}
|
|
2372
|
+
certPem = await fallbackResp.text();
|
|
2373
|
+
}
|
|
2374
|
+
finally {
|
|
2375
|
+
clearTimeout(fallbackTimeoutId);
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
// H7: 严格校验指纹(DER SHA-256 或 SPKI SHA-256 任一匹配即可)
|
|
2379
|
+
if (certFingerprint) {
|
|
2380
|
+
const expectedFP = String(certFingerprint).trim().toLowerCase();
|
|
2381
|
+
if (!expectedFP.startsWith('sha256:')) {
|
|
2382
|
+
throw new ValidationError(`unsupported cert_fingerprint format for ${aid}: ${expectedFP.slice(0, 24)}`);
|
|
2383
|
+
}
|
|
2384
|
+
const derFP = await this._certFingerprint(certPem);
|
|
2385
|
+
if (derFP !== expectedFP) {
|
|
2386
|
+
const spkiFP = await this._spkiFingerprint(certPem);
|
|
2387
|
+
if (!spkiFP || spkiFP !== expectedFP) {
|
|
2388
|
+
throw new ValidationError(`peer cert fingerprint mismatch for ${aid}: expected=${expectedFP.slice(0, 24)}...`);
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
// 完整 PKI 验证:链 + CRL + OCSP + AID 绑定
|
|
2393
|
+
try {
|
|
2394
|
+
await this._auth.verifyPeerCertificate(peerGatewayUrl, certPem, aid);
|
|
2395
|
+
}
|
|
2396
|
+
catch (exc) {
|
|
2397
|
+
throw new ValidationError(`peer cert verification failed for ${aid}: ${exc}`);
|
|
2398
|
+
}
|
|
2399
|
+
this._certCache.set(cacheKey, {
|
|
2400
|
+
certPem,
|
|
2401
|
+
validatedAt: now,
|
|
2402
|
+
refreshAfter: now + PEER_CERT_CACHE_TTL,
|
|
2403
|
+
});
|
|
2404
|
+
try {
|
|
2405
|
+
// peer 证书只存版本目录,不覆盖 cert.pem
|
|
2406
|
+
await this._keystore.saveCert(aid, certPem, certFingerprint, { makeActive: false });
|
|
2407
|
+
}
|
|
2408
|
+
catch (exc) {
|
|
2409
|
+
console.error(`写入证书到 keystore 失败 (aid=${aid}):`, exc);
|
|
2410
|
+
}
|
|
2411
|
+
return certPem;
|
|
2412
|
+
}
|
|
2413
|
+
/** 获取对方所有设备的 prekey(带缓存)。 */
|
|
2414
|
+
async _fetchPeerPrekeys(peerAid) {
|
|
2415
|
+
const cachedList = this._peerPrekeysCache.get(peerAid);
|
|
2416
|
+
if (cachedList && Date.now() / 1000 < cachedList.expireAt) {
|
|
2417
|
+
return cachedList.items.map((item) => ({ ...item }));
|
|
2418
|
+
}
|
|
2419
|
+
const cached = this._e2ee.getCachedPrekey(peerAid);
|
|
2420
|
+
if (cached !== null && isPeerPrekeyMaterial(cached)) {
|
|
2421
|
+
return [{ ...cached }];
|
|
2422
|
+
}
|
|
2423
|
+
let result;
|
|
2424
|
+
try {
|
|
2425
|
+
result = await this._transport.call('message.e2ee.get_prekey', { aid: peerAid });
|
|
2426
|
+
}
|
|
2427
|
+
catch (exc) {
|
|
2428
|
+
throw new ValidationError(`failed to fetch peer prekey for ${peerAid}: ${String(exc)}`);
|
|
2429
|
+
}
|
|
2430
|
+
if (!isJsonObject(result)) {
|
|
2431
|
+
throw new ValidationError(`invalid prekey response for ${peerAid}`);
|
|
2432
|
+
}
|
|
2433
|
+
if (result.found === false) {
|
|
2434
|
+
return [];
|
|
2435
|
+
}
|
|
2436
|
+
const devicePrekeys = Array.isArray(result.device_prekeys) ? result.device_prekeys : null;
|
|
2437
|
+
if (devicePrekeys) {
|
|
2438
|
+
const normalized = devicePrekeys.filter(isPeerPrekeyMaterial).map((item) => ({ ...item }));
|
|
2439
|
+
if (normalized.length > 0) {
|
|
2440
|
+
this._peerPrekeysCache.set(peerAid, {
|
|
2441
|
+
items: normalized.map((item) => ({ ...item })),
|
|
2442
|
+
expireAt: Date.now() / 1000 + 300,
|
|
2443
|
+
});
|
|
2444
|
+
this._e2ee.cachePrekey(peerAid, normalized[0]);
|
|
2445
|
+
return normalized;
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
if (!isPeerPrekeyResponse(result)) {
|
|
2449
|
+
throw new ValidationError(`invalid prekey response for ${peerAid}`);
|
|
2450
|
+
}
|
|
2451
|
+
if (result.prekey) {
|
|
2452
|
+
this._peerPrekeysCache.set(peerAid, {
|
|
2453
|
+
items: [{ ...result.prekey }],
|
|
2454
|
+
expireAt: Date.now() / 1000 + 300,
|
|
2455
|
+
});
|
|
2456
|
+
this._e2ee.cachePrekey(peerAid, result.prekey);
|
|
2457
|
+
return [{ ...result.prekey }];
|
|
2458
|
+
}
|
|
2459
|
+
if (result.found) {
|
|
2460
|
+
throw new ValidationError(`invalid prekey response for ${peerAid}`);
|
|
2461
|
+
}
|
|
2462
|
+
return [];
|
|
2463
|
+
}
|
|
2464
|
+
/** 获取对方 prekey(兼容接口,优先返回第一条 device prekey)。 */
|
|
2465
|
+
async _fetchPeerPrekey(peerAid) {
|
|
2466
|
+
const cachedList = this._peerPrekeysCache.get(peerAid);
|
|
2467
|
+
if (cachedList && Date.now() / 1000 < cachedList.expireAt && cachedList.items.length > 0) {
|
|
2468
|
+
return { ...cachedList.items[0] };
|
|
2469
|
+
}
|
|
2470
|
+
const prekeys = await this._fetchPeerPrekeys(peerAid);
|
|
2471
|
+
if (prekeys.length === 0) {
|
|
2472
|
+
return null;
|
|
2473
|
+
}
|
|
2474
|
+
return { ...prekeys[0] };
|
|
2475
|
+
}
|
|
2476
|
+
/** 生成 prekey 并上传到服务端 */
|
|
2477
|
+
async _uploadPrekey() {
|
|
2478
|
+
const prekeyMaterial = await this._e2ee.generatePrekey();
|
|
2479
|
+
const result = await this._transport.call('message.e2ee.put_prekey', prekeyMaterial);
|
|
2480
|
+
return isJsonObject(result) ? { ...result } : { ok: true };
|
|
2481
|
+
}
|
|
2482
|
+
/** 确保发送方证书在本地可用且未过期 */
|
|
2483
|
+
async _ensureSenderCertCached(aid, certFingerprint) {
|
|
2484
|
+
const cacheKey = certCacheKey(aid, certFingerprint);
|
|
2485
|
+
const cached = this._certCache.get(cacheKey);
|
|
2486
|
+
const now = Date.now() / 1000;
|
|
2487
|
+
if (cached && now < cached.refreshAfter)
|
|
2488
|
+
return true;
|
|
2489
|
+
const localCert = await this._keystore.loadCert(aid, certFingerprint);
|
|
2490
|
+
if (localCert) {
|
|
2491
|
+
if (certFingerprint) {
|
|
2492
|
+
const actualFingerprint = await this._certFingerprint(localCert);
|
|
2493
|
+
if (actualFingerprint === String(certFingerprint).trim().toLowerCase()) {
|
|
2494
|
+
this._certCache.set(cacheKey, {
|
|
2495
|
+
certPem: localCert,
|
|
2496
|
+
validatedAt: now,
|
|
2497
|
+
refreshAfter: now + PEER_CERT_CACHE_TTL,
|
|
2498
|
+
});
|
|
2499
|
+
return true;
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
else {
|
|
2503
|
+
this._certCache.set(cacheKey, {
|
|
2504
|
+
certPem: localCert,
|
|
2505
|
+
validatedAt: now,
|
|
2506
|
+
refreshAfter: now + PEER_CERT_CACHE_TTL,
|
|
2507
|
+
});
|
|
2508
|
+
return true;
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
try {
|
|
2512
|
+
const certPem = await this._fetchPeerCert(aid, certFingerprint);
|
|
2513
|
+
// peer 证书只存版本目录,不覆盖 cert.pem
|
|
2514
|
+
await this._keystore.saveCert(aid, certPem, certFingerprint, { makeActive: false });
|
|
2515
|
+
return true;
|
|
2516
|
+
}
|
|
2517
|
+
catch (exc) {
|
|
2518
|
+
// 刷新失败时:若缓存有 PKI 验证过的证书(2 倍 TTL 内)则继续用
|
|
2519
|
+
if (cached && now < cached.validatedAt + PEER_CERT_CACHE_TTL * 2) {
|
|
2520
|
+
console.warn(`刷新发送方 ${aid} 证书失败,继续使用已验证的内存缓存:`, exc);
|
|
2521
|
+
return true;
|
|
2522
|
+
}
|
|
2523
|
+
console.warn(`获取发送方 ${aid} 证书失败且无已验证缓存,拒绝信任:`, exc);
|
|
2524
|
+
return false;
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
/**
|
|
2528
|
+
* 获取经过 PKI 验证的 peer 证书(仅信任内存缓存中已验证的证书)。
|
|
2529
|
+
* 零信任要求:不直接信任 keystore 中可能由恶意服务端注入的证书。
|
|
2530
|
+
*/
|
|
2531
|
+
_getVerifiedPeerCert(aid, certFingerprint) {
|
|
2532
|
+
const cached = this._certCache.get(certCacheKey(aid, certFingerprint));
|
|
2533
|
+
const now = Date.now() / 1000;
|
|
2534
|
+
if (cached && now < cached.validatedAt + PEER_CERT_CACHE_TTL * 2) {
|
|
2535
|
+
return cached.certPem;
|
|
2536
|
+
}
|
|
2537
|
+
return null;
|
|
2538
|
+
}
|
|
2539
|
+
// ── 客户端操作签名 ────────────────────────────────
|
|
2540
|
+
/**
|
|
2541
|
+
* 为关键操作附加客户端 ECDSA 签名(_client_signature 字段)。
|
|
2542
|
+
* 使用 SubtleCrypto 异步签名。
|
|
2543
|
+
*/
|
|
2544
|
+
async _signClientOperation(method, params) {
|
|
2545
|
+
const identity = this._identity;
|
|
2546
|
+
if (!identity || !identity.private_key_pem)
|
|
2547
|
+
return;
|
|
2548
|
+
try {
|
|
2549
|
+
const aid = (identity.aid ?? '');
|
|
2550
|
+
const ts = String(Math.floor(Date.now() / 1000));
|
|
2551
|
+
// 计算 params hash:覆盖所有非 _ 前缀且非 client_signature 的业务字段
|
|
2552
|
+
const paramsForHash = {};
|
|
2553
|
+
for (const [k, v] of Object.entries(params)) {
|
|
2554
|
+
if (k !== 'client_signature' && !k.startsWith('_')) {
|
|
2555
|
+
paramsForHash[k] = v;
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
const paramsJson = stableStringify(paramsForHash);
|
|
2559
|
+
// SHA-256 hash
|
|
2560
|
+
const paramsHashBuf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(paramsJson));
|
|
2561
|
+
const paramsHash = Array.from(new Uint8Array(paramsHashBuf))
|
|
2562
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
2563
|
+
.join('');
|
|
2564
|
+
const signData = new TextEncoder().encode(`${method}|${aid}|${ts}|${paramsHash}`);
|
|
2565
|
+
// 导入私钥并签名
|
|
2566
|
+
const pkcs8 = pemToArrayBuffer(identity.private_key_pem);
|
|
2567
|
+
const cryptoKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);
|
|
2568
|
+
const sigP1363 = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, cryptoKey, signData);
|
|
2569
|
+
// P1363 → DER 格式(与 Python 兼容)
|
|
2570
|
+
const sigDer = p1363ToDer(new Uint8Array(sigP1363));
|
|
2571
|
+
// 证书指纹
|
|
2572
|
+
let certFingerprint = '';
|
|
2573
|
+
const certPem = (identity.cert ?? '');
|
|
2574
|
+
if (certPem) {
|
|
2575
|
+
const certDer = pemToArrayBuffer(certPem);
|
|
2576
|
+
const fpBuf = await crypto.subtle.digest('SHA-256', certDer);
|
|
2577
|
+
certFingerprint = 'sha256:' + Array.from(new Uint8Array(fpBuf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
2578
|
+
}
|
|
2579
|
+
params.client_signature = {
|
|
2580
|
+
aid,
|
|
2581
|
+
cert_fingerprint: certFingerprint,
|
|
2582
|
+
timestamp: ts,
|
|
2583
|
+
params_hash: paramsHash,
|
|
2584
|
+
signature: uint8ToBase64(sigDer),
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2587
|
+
catch (exc) {
|
|
2588
|
+
throw new E2EEError(`客户端签名失败: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
// ── E2EE 编排:Group 生命周期 ─────────────────────
|
|
2592
|
+
_attachRotationId(info, rotationId) {
|
|
2593
|
+
if (!rotationId || !Array.isArray(info.distributions))
|
|
2594
|
+
return;
|
|
2595
|
+
for (const dist of info.distributions) {
|
|
2596
|
+
if (isJsonObject(dist) && isJsonObject(dist.payload)) {
|
|
2597
|
+
dist.payload.rotation_id = rotationId;
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
async _getGroupMemberAids(groupId) {
|
|
2602
|
+
const membersResult = await this.call('group.get_members', { group_id: groupId });
|
|
2603
|
+
const rawList = isJsonObject(membersResult)
|
|
2604
|
+
? (Array.isArray(membersResult.members) ? membersResult.members : membersResult.items)
|
|
2605
|
+
: [];
|
|
2606
|
+
if (!Array.isArray(rawList))
|
|
2607
|
+
return [];
|
|
2608
|
+
const aids = [];
|
|
2609
|
+
for (const item of rawList) {
|
|
2610
|
+
if (!isJsonObject(item))
|
|
2611
|
+
continue;
|
|
2612
|
+
const aid = String(item.aid ?? '').trim();
|
|
2613
|
+
if (aid)
|
|
2614
|
+
aids.push(aid);
|
|
2615
|
+
}
|
|
2616
|
+
return aids;
|
|
2617
|
+
}
|
|
2618
|
+
async _distributeGroupEpochKey(info, rotationId = '') {
|
|
2619
|
+
const sent = [];
|
|
2620
|
+
const failed = [];
|
|
2621
|
+
let lastHeartbeat = Date.now();
|
|
2622
|
+
const distributions = (Array.isArray(info.distributions) ? info.distributions : []);
|
|
2623
|
+
for (const dist of distributions) {
|
|
2624
|
+
if (!isJsonObject(dist) || !dist.to || !isJsonObject(dist.payload))
|
|
2625
|
+
continue;
|
|
2626
|
+
if (rotationId && Date.now() - lastHeartbeat >= 20_000) {
|
|
2627
|
+
if (await this._heartbeatGroupRotation(rotationId))
|
|
2628
|
+
lastHeartbeat = Date.now();
|
|
2629
|
+
}
|
|
2630
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
2631
|
+
try {
|
|
2632
|
+
await this.call('message.send', {
|
|
2633
|
+
to: dist.to,
|
|
2634
|
+
payload: dist.payload,
|
|
2635
|
+
encrypt: true,
|
|
2636
|
+
persist_required: true,
|
|
2637
|
+
});
|
|
2638
|
+
sent.push(String(dist.to));
|
|
2639
|
+
break;
|
|
2640
|
+
}
|
|
2641
|
+
catch (exc) {
|
|
2642
|
+
if (attempt < 2) {
|
|
2643
|
+
await this._sleep((attempt + 1) * 1000);
|
|
2644
|
+
}
|
|
2645
|
+
else {
|
|
2646
|
+
failed.push(String(dist.to));
|
|
2647
|
+
console.warn('epoch 密钥分发失败 (to=%s):', dist.to, exc);
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
return { sent, failed };
|
|
2653
|
+
}
|
|
2654
|
+
async _heartbeatGroupRotation(rotationId) {
|
|
2655
|
+
if (!rotationId)
|
|
2656
|
+
return false;
|
|
2657
|
+
try {
|
|
2658
|
+
const result = await this.call('group.e2ee.heartbeat_rotation', {
|
|
2659
|
+
rotation_id: rotationId,
|
|
2660
|
+
lease_ms: GROUP_ROTATION_LEASE_MS,
|
|
2661
|
+
});
|
|
2662
|
+
return isJsonObject(result) && result.success === true;
|
|
2663
|
+
}
|
|
2664
|
+
catch (exc) {
|
|
2665
|
+
console.warn(`[aun_core] 刷新 epoch rotation lease 失败: rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
2666
|
+
return false;
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
async _ackGroupRotationKey(rotationId, keyCommitment, deviceId) {
|
|
2670
|
+
if (!rotationId)
|
|
2671
|
+
return false;
|
|
2672
|
+
try {
|
|
2673
|
+
const result = await this.call('group.e2ee.ack_rotation_key', {
|
|
2674
|
+
rotation_id: rotationId,
|
|
2675
|
+
key_commitment: keyCommitment,
|
|
2676
|
+
device_id: deviceId ?? this._deviceId,
|
|
2677
|
+
});
|
|
2678
|
+
return isJsonObject(result) && result.success === true;
|
|
2679
|
+
}
|
|
2680
|
+
catch (exc) {
|
|
2681
|
+
console.warn(`[aun_core] 提交 epoch key ack 失败: rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
2682
|
+
return false;
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
async _verifyActiveGroupRotationDistribution(payload) {
|
|
2686
|
+
const rotationId = String(payload.rotation_id ?? '').trim();
|
|
2687
|
+
const groupId = String(payload.group_id ?? '').trim();
|
|
2688
|
+
if (!groupId)
|
|
2689
|
+
return false;
|
|
2690
|
+
const epoch = Number(payload.epoch ?? 0);
|
|
2691
|
+
if (!Number.isFinite(epoch) || epoch <= 0)
|
|
2692
|
+
return false;
|
|
2693
|
+
const commitment = String(payload.commitment ?? '').trim();
|
|
2694
|
+
try {
|
|
2695
|
+
const epochResult = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
2696
|
+
if (!isJsonObject(epochResult))
|
|
2697
|
+
return false;
|
|
2698
|
+
const committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
|
|
2699
|
+
const committedEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
2700
|
+
if (!rotationId) {
|
|
2701
|
+
if (Number.isFinite(epoch) && epoch > 0 && epoch <= committedEpoch) {
|
|
2702
|
+
if (committedRotation && Number(committedRotation.target_epoch ?? committedEpoch) === epoch) {
|
|
2703
|
+
const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
|
|
2704
|
+
if (committedCommitment && commitment && committedCommitment !== commitment)
|
|
2705
|
+
return false;
|
|
2706
|
+
}
|
|
2707
|
+
return true;
|
|
2708
|
+
}
|
|
2709
|
+
console.info(`[aun_core] 拒绝缺少 rotation_id 的未来 epoch key 分发: group=${groupId} epoch=${epoch} committed=${committedEpoch}`);
|
|
2710
|
+
return false;
|
|
2711
|
+
}
|
|
2712
|
+
const pending = isJsonObject(epochResult.pending_rotation) ? epochResult.pending_rotation : null;
|
|
2713
|
+
if (pending && !pending.expired) {
|
|
2714
|
+
const pendingCommitment = String(pending.key_commitment ?? '').trim();
|
|
2715
|
+
if (String(pending.rotation_id ?? '') === rotationId
|
|
2716
|
+
&& Number(pending.target_epoch ?? 0) === epoch
|
|
2717
|
+
&& (!pendingCommitment || pendingCommitment === commitment)) {
|
|
2718
|
+
return true;
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
if (committedRotation && committedEpoch >= epoch) {
|
|
2722
|
+
const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
|
|
2723
|
+
if (String(committedRotation.rotation_id ?? '') === rotationId
|
|
2724
|
+
&& (!committedCommitment || committedCommitment === commitment)) {
|
|
2725
|
+
return true;
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
catch (exc) {
|
|
2730
|
+
console.warn(`[aun_core] 拒绝无法校验 active rotation 的 epoch key 分发: group=${groupId} rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
2731
|
+
return false;
|
|
2732
|
+
}
|
|
2733
|
+
console.info(`[aun_core] 拒绝非 pending/committed 状态的 epoch key 分发: group=${groupId} rotation=${rotationId} epoch=${epoch}`);
|
|
2734
|
+
return false;
|
|
2735
|
+
}
|
|
2736
|
+
async _discardGroupDistributionIfStale(payload) {
|
|
2737
|
+
const rotationId = String(payload.rotation_id ?? '').trim();
|
|
2738
|
+
if (!rotationId)
|
|
2739
|
+
return;
|
|
2740
|
+
const groupId = String(payload.group_id ?? '').trim();
|
|
2741
|
+
const epoch = Number(payload.epoch ?? 0);
|
|
2742
|
+
if (!groupId || !Number.isFinite(epoch) || epoch <= 0)
|
|
2743
|
+
return;
|
|
2744
|
+
if (await this._verifyActiveGroupRotationDistribution(payload))
|
|
2745
|
+
return;
|
|
2746
|
+
try {
|
|
2747
|
+
await this._groupE2ee.discardPendingSecret(groupId, epoch, rotationId);
|
|
2748
|
+
console.info('丢弃 verify 后变为 stale 的 group epoch key: group=%s epoch=%s rotation=%s', groupId, epoch, rotationId);
|
|
2749
|
+
}
|
|
2750
|
+
catch (exc) {
|
|
2751
|
+
console.debug('清理 stale group epoch key 失败: group=%s epoch=%s rotation=%s err=%s', groupId, epoch, rotationId, formatCaughtError(exc));
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
async _verifyGroupKeyResponseEpoch(payload) {
|
|
2755
|
+
const groupId = String(payload.group_id ?? '').trim();
|
|
2756
|
+
if (!groupId)
|
|
2757
|
+
return false;
|
|
2758
|
+
const epoch = Number(payload.epoch ?? 0);
|
|
2759
|
+
if (!Number.isFinite(epoch) || epoch <= 0)
|
|
2760
|
+
return false;
|
|
2761
|
+
const commitment = String(payload.commitment ?? '').trim();
|
|
2762
|
+
try {
|
|
2763
|
+
const epochResult = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
2764
|
+
if (!isJsonObject(epochResult))
|
|
2765
|
+
return false;
|
|
2766
|
+
const committedEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
2767
|
+
if (epoch > committedEpoch) {
|
|
2768
|
+
console.info(`[aun_core] 拒绝未提交 epoch 的 group key response: group=${groupId} epoch=${epoch} committed=${committedEpoch}`);
|
|
2769
|
+
return false;
|
|
2770
|
+
}
|
|
2771
|
+
const committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
|
|
2772
|
+
if (committedRotation && Number(committedRotation.target_epoch ?? committedEpoch) === epoch) {
|
|
2773
|
+
const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
|
|
2774
|
+
if (committedCommitment && commitment && committedCommitment !== commitment)
|
|
2775
|
+
return false;
|
|
2776
|
+
}
|
|
2777
|
+
return true;
|
|
2778
|
+
}
|
|
2779
|
+
catch (exc) {
|
|
2780
|
+
console.warn(`[aun_core] 拒绝无法校验 committed epoch 的 group key response: group=${groupId} epoch=${epoch} err=${formatCaughtError(exc)}`);
|
|
2781
|
+
return false;
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
async _abortGroupRotation(rotationId, reason = '') {
|
|
2785
|
+
if (!rotationId)
|
|
2786
|
+
return false;
|
|
2787
|
+
try {
|
|
2788
|
+
const result = await this.call('group.e2ee.abort_rotation', {
|
|
2789
|
+
rotation_id: rotationId,
|
|
2790
|
+
reason: reason || 'client_abort',
|
|
2791
|
+
});
|
|
2792
|
+
return isJsonObject(result) && result.success === true;
|
|
2793
|
+
}
|
|
2794
|
+
catch (exc) {
|
|
2795
|
+
console.warn(`[aun_core] 中止 epoch rotation 失败: rotation=${rotationId} err=${formatCaughtError(exc)}`);
|
|
2796
|
+
return false;
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
_rotationExpectedMembersStale(rotation, memberAids) {
|
|
2800
|
+
const expected = Array.isArray(rotation.expected_members)
|
|
2801
|
+
? rotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean).sort()
|
|
2802
|
+
: [];
|
|
2803
|
+
const current = memberAids.map((item) => String(item ?? '').trim()).filter(Boolean).sort();
|
|
2804
|
+
return expected.length > 0 && current.length > 0 && expected.join('\n') !== current.join('\n');
|
|
2805
|
+
}
|
|
2806
|
+
_rotationRetryDelayMs(pending) {
|
|
2807
|
+
let leaseExpiresAt = 0;
|
|
2808
|
+
if (pending && !pending.expired && ['', 'distributing'].includes(String(pending.status ?? ''))) {
|
|
2809
|
+
const parsed = Number(pending.lease_expires_at ?? 0);
|
|
2810
|
+
if (Number.isFinite(parsed))
|
|
2811
|
+
leaseExpiresAt = parsed;
|
|
2812
|
+
}
|
|
2813
|
+
const base = leaseExpiresAt
|
|
2814
|
+
? Math.max(1000, leaseExpiresAt - Date.now() + 1000)
|
|
2815
|
+
: 5000;
|
|
2816
|
+
return Math.min(base + Math.floor(Math.random() * 2000), GROUP_ROTATION_RETRY_MAX_DELAY_MS);
|
|
2817
|
+
}
|
|
2818
|
+
_scheduleGroupRotationRetry(groupId, opts) {
|
|
2819
|
+
if (this._closing || this._state !== 'connected')
|
|
2820
|
+
return;
|
|
2821
|
+
const retryKey = `${groupId}:${opts.triggerId || opts.reason}:${opts.expectedEpoch ?? '-'}`;
|
|
2822
|
+
if (this._groupEpochRotationRetryTimers.has(retryKey))
|
|
2823
|
+
return;
|
|
2824
|
+
const timer = setTimeout(() => {
|
|
2825
|
+
this._groupEpochRotationRetryTimers.delete(retryKey);
|
|
2826
|
+
if (this._closing || this._state !== 'connected')
|
|
2827
|
+
return;
|
|
2828
|
+
this._safeAsync(this._maybeLeadRotateGroupEpoch(groupId, opts.triggerId, opts.expectedEpoch));
|
|
2829
|
+
}, this._rotationRetryDelayMs(opts.pending));
|
|
2830
|
+
this._groupEpochRotationRetryTimers.set(retryKey, timer);
|
|
2831
|
+
}
|
|
2832
|
+
/** 建群后将本地 epoch 1 同步到服务端,最多重试 3 次 */
|
|
2833
|
+
async _syncEpochToServer(groupId) {
|
|
2834
|
+
const started = Date.now();
|
|
2835
|
+
while (this._groupEpochRotationInflight.has(groupId)) {
|
|
2836
|
+
if (this._closing || this._state !== 'connected')
|
|
2837
|
+
return;
|
|
2838
|
+
if (Date.now() - started > 20000) {
|
|
2839
|
+
console.warn('group epoch create sync still in-flight; skip duplicate sync (group=%s)', groupId);
|
|
2840
|
+
return;
|
|
2841
|
+
}
|
|
2842
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
2843
|
+
}
|
|
2844
|
+
if (this._closing || this._state !== 'connected')
|
|
2845
|
+
return;
|
|
2846
|
+
this._groupEpochRotationInflight.add(groupId);
|
|
2847
|
+
try {
|
|
2848
|
+
const maxRetries = 3;
|
|
2849
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
2850
|
+
try {
|
|
2851
|
+
if (!this._aid)
|
|
2852
|
+
return;
|
|
2853
|
+
const secretData = await this._groupE2ee.loadSecret(groupId, 1);
|
|
2854
|
+
if (!secretData)
|
|
2855
|
+
throw new StateError(`group ${groupId} epoch 1 secret missing`);
|
|
2856
|
+
const rotationId = `rot-${_uuidV4().replace(/-/g, '')}`;
|
|
2857
|
+
const rotateParams = {
|
|
2858
|
+
group_id: groupId,
|
|
2859
|
+
base_epoch: 0,
|
|
2860
|
+
target_epoch: 1,
|
|
2861
|
+
rotation_id: rotationId,
|
|
2862
|
+
reason: 'create_group',
|
|
2863
|
+
key_commitment: secretData.commitment,
|
|
2864
|
+
expected_members: secretData.member_aids.length > 0 ? secretData.member_aids : [this._aid],
|
|
2865
|
+
required_acks: [this._aid],
|
|
2866
|
+
lease_ms: GROUP_ROTATION_LEASE_MS,
|
|
2867
|
+
};
|
|
2868
|
+
const sigParams = await this._buildRotationSignature(groupId, 0, 1, rotateParams);
|
|
2869
|
+
Object.assign(rotateParams, sigParams);
|
|
2870
|
+
const beginResult = await this.call('group.e2ee.begin_rotation', rotateParams);
|
|
2871
|
+
const rotation = isJsonObject(beginResult) && isJsonObject(beginResult.rotation) ? beginResult.rotation : null;
|
|
2872
|
+
if (!isJsonObject(beginResult) || beginResult.success !== true || !rotation) {
|
|
2873
|
+
console.warn('group epoch begin failed; stop key distribution (group=%s, returned=%s)', groupId, JSON.stringify(beginResult));
|
|
2874
|
+
return;
|
|
2875
|
+
}
|
|
2876
|
+
const activeRotationId = String(rotation.rotation_id ?? rotationId);
|
|
2877
|
+
if (!await this._ackGroupRotationKey(activeRotationId, secretData.commitment)) {
|
|
2878
|
+
console.warn('group epoch self ack failed (group=%s, rotation=%s)', groupId, activeRotationId);
|
|
2879
|
+
await this._abortGroupRotation(activeRotationId, 'self_ack_failed');
|
|
2880
|
+
return;
|
|
2881
|
+
}
|
|
2882
|
+
const commitResult = await this.call('group.e2ee.commit_rotation', { rotation_id: activeRotationId });
|
|
2883
|
+
if (isJsonObject(commitResult) && commitResult.success === true) {
|
|
2884
|
+
await storeGroupSecret(this._keystore, this._aid, groupId, 1, secretData.secret, secretData.commitment, secretData.member_aids, secretData.epoch_chain);
|
|
2885
|
+
return;
|
|
2886
|
+
}
|
|
2887
|
+
console.warn('group epoch commit failed (group=%s, returned=%s)', groupId, JSON.stringify(commitResult));
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
catch (exc) {
|
|
2891
|
+
if (attempt < maxRetries) {
|
|
2892
|
+
const delay = 500 * Math.pow(2, attempt - 1);
|
|
2893
|
+
console.warn(`同步 epoch 到服务端失败 (group=${groupId}, 第${attempt}/${maxRetries}次), ${delay}ms后重试:`, exc);
|
|
2894
|
+
await new Promise(r => setTimeout(r, delay));
|
|
2895
|
+
}
|
|
2896
|
+
else {
|
|
2897
|
+
console.error(`同步 epoch 到服务端最终失败 (group=${groupId}, 已重试${maxRetries}次):`, exc);
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
finally {
|
|
2903
|
+
this._groupEpochRotationInflight.delete(groupId);
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
/**
|
|
2907
|
+
* H21: 基于"排序最小 admin = leader"选举,其他 admin 走 jitter 兜底重试。
|
|
2908
|
+
* 避免所有剩余 admin 同时触发 `_rotateGroupEpoch` 造成 CAS 风暴。
|
|
2909
|
+
*/
|
|
2910
|
+
async _maybeLeadRotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null) {
|
|
2911
|
+
const myAid = this._aid;
|
|
2912
|
+
if (!myAid || this._closing || this._state !== 'connected')
|
|
2913
|
+
return;
|
|
2914
|
+
const started = Date.now();
|
|
2915
|
+
while (this._groupEpochRotationInflight.has(groupId)) {
|
|
2916
|
+
if (triggerId && this._groupMembershipRotationDone.has(triggerId))
|
|
2917
|
+
return;
|
|
2918
|
+
if (this._closing || this._state !== 'connected')
|
|
2919
|
+
return;
|
|
2920
|
+
if (Date.now() - started > 20000) {
|
|
2921
|
+
console.warn('group epoch rotation still in-flight; skip pending trigger (group=%s trigger=%s)', groupId, triggerId || '-');
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
2925
|
+
}
|
|
2926
|
+
if (this._closing || this._state !== 'connected')
|
|
2927
|
+
return;
|
|
2928
|
+
this._groupEpochRotationInflight.add(groupId);
|
|
2929
|
+
try {
|
|
2930
|
+
if (this._closing || this._state !== 'connected')
|
|
2931
|
+
return;
|
|
2932
|
+
const membersResp = await this.call('group.get_members', { group_id: groupId });
|
|
2933
|
+
if (!isJsonObject(membersResp))
|
|
2934
|
+
return;
|
|
2935
|
+
const rawList = membersResp.members ?? membersResp.items;
|
|
2936
|
+
if (!Array.isArray(rawList))
|
|
2937
|
+
return;
|
|
2938
|
+
const admins = [];
|
|
2939
|
+
for (const m of rawList) {
|
|
2940
|
+
if (!isJsonObject(m))
|
|
2941
|
+
continue;
|
|
2942
|
+
const role = String(m.role ?? '');
|
|
2943
|
+
const aid = String(m.aid ?? '');
|
|
2944
|
+
if (aid && (role === 'admin' || role === 'owner'))
|
|
2945
|
+
admins.push(aid);
|
|
2946
|
+
}
|
|
2947
|
+
if (admins.length === 0)
|
|
2948
|
+
return;
|
|
2949
|
+
admins.sort();
|
|
2950
|
+
const leader = admins[0];
|
|
2951
|
+
if (leader === myAid) {
|
|
2952
|
+
// 我是 leader,直接发起
|
|
2953
|
+
await this._rotateGroupEpoch(groupId, triggerId, expectedEpoch);
|
|
2954
|
+
return;
|
|
2955
|
+
}
|
|
2956
|
+
if (!admins.includes(myAid))
|
|
2957
|
+
return;
|
|
2958
|
+
// 非 leader:随机 jitter(2~6s)后查询服务端 epoch 是否已被 leader 推进
|
|
2959
|
+
const jitterMs = 2000 + Math.floor(Math.random() * 4000);
|
|
2960
|
+
let beforeEpoch = 0;
|
|
2961
|
+
try {
|
|
2962
|
+
const resp = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
2963
|
+
if (isJsonObject(resp))
|
|
2964
|
+
beforeEpoch = Number(resp.epoch ?? 0);
|
|
2965
|
+
}
|
|
2966
|
+
catch {
|
|
2967
|
+
beforeEpoch = (await this._groupE2ee.currentEpoch(groupId)) ?? 0;
|
|
2968
|
+
}
|
|
2969
|
+
await new Promise((r) => setTimeout(r, jitterMs));
|
|
2970
|
+
if (this._closing || this._state !== 'connected')
|
|
2971
|
+
return;
|
|
2972
|
+
let afterEpoch = 0;
|
|
2973
|
+
let afterResp = {};
|
|
2974
|
+
try {
|
|
2975
|
+
afterResp = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
2976
|
+
if (isJsonObject(afterResp))
|
|
2977
|
+
afterEpoch = Number(afterResp.epoch ?? 0);
|
|
2978
|
+
}
|
|
2979
|
+
catch {
|
|
2980
|
+
afterEpoch = (await this._groupE2ee.currentEpoch(groupId)) ?? 0;
|
|
2981
|
+
}
|
|
2982
|
+
if (afterEpoch > beforeEpoch)
|
|
2983
|
+
return; // leader 已完成
|
|
2984
|
+
const pending = isJsonObject(afterResp) && isJsonObject(afterResp.pending_rotation) ? afterResp.pending_rotation : null;
|
|
2985
|
+
if (pending && !pending.expired) {
|
|
2986
|
+
this._scheduleGroupRotationRetry(groupId, {
|
|
2987
|
+
reason: 'membership_changed',
|
|
2988
|
+
triggerId,
|
|
2989
|
+
expectedEpoch,
|
|
2990
|
+
pending,
|
|
2991
|
+
});
|
|
2992
|
+
return;
|
|
2993
|
+
}
|
|
2994
|
+
console.info('[H21] leader 未完成 epoch 轮换,非 leader 兜底: group=%s myAid=%s', groupId, myAid);
|
|
2995
|
+
await this._rotateGroupEpoch(groupId, triggerId, expectedEpoch);
|
|
2996
|
+
}
|
|
2997
|
+
catch (exc) {
|
|
2998
|
+
console.warn('_maybeLeadRotateGroupEpoch 失败: %s', exc);
|
|
2999
|
+
}
|
|
3000
|
+
finally {
|
|
3001
|
+
this._groupEpochRotationInflight.delete(groupId);
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
/**
|
|
3005
|
+
* 为指定群组轮换 epoch 并分发新密钥。
|
|
3006
|
+
* 使用服务端 CAS 保证只有一方成功。
|
|
3007
|
+
*/
|
|
3008
|
+
async _rotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null) {
|
|
3009
|
+
try {
|
|
3010
|
+
if (!this._aid)
|
|
3011
|
+
return;
|
|
3012
|
+
const memberAids = await this._getGroupMemberAids(groupId);
|
|
3013
|
+
if (triggerId && this._groupMembershipRotationDone.has(triggerId))
|
|
3014
|
+
return;
|
|
3015
|
+
const epochResult = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
3016
|
+
const serverEpoch = isJsonObject(epochResult) ? Number(epochResult.epoch ?? 0) : 0;
|
|
3017
|
+
const pendingRotation = isJsonObject(epochResult) && isJsonObject(epochResult.pending_rotation)
|
|
3018
|
+
? epochResult.pending_rotation
|
|
3019
|
+
: null;
|
|
3020
|
+
if (pendingRotation && !pendingRotation.expired) {
|
|
3021
|
+
const pendingRotationId = String(pendingRotation.rotation_id ?? '');
|
|
3022
|
+
const stalePending = (expectedEpoch !== null
|
|
3023
|
+
&& serverEpoch === expectedEpoch
|
|
3024
|
+
&& this._rotationExpectedMembersStale(pendingRotation, memberAids));
|
|
3025
|
+
if (stalePending && await this._abortGroupRotation(pendingRotationId, 'membership_changed_during_rotation')) {
|
|
3026
|
+
console.info('aborted stale pending group epoch rotation: group=%s rotation=%s', groupId, pendingRotationId || '-');
|
|
3027
|
+
}
|
|
3028
|
+
else {
|
|
3029
|
+
this._scheduleGroupRotationRetry(groupId, {
|
|
3030
|
+
reason: 'membership_changed',
|
|
3031
|
+
triggerId,
|
|
3032
|
+
expectedEpoch,
|
|
3033
|
+
pending: pendingRotation,
|
|
3034
|
+
});
|
|
3035
|
+
return;
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
if (expectedEpoch !== null && serverEpoch !== expectedEpoch) {
|
|
3039
|
+
if (triggerId)
|
|
3040
|
+
this._groupMembershipRotationDone.add(triggerId);
|
|
3041
|
+
console.info('skip membership epoch rotation: group=%s expected_epoch=%d server_epoch=%d trigger=%s', groupId, expectedEpoch, serverEpoch, triggerId || '-');
|
|
3042
|
+
return;
|
|
3043
|
+
}
|
|
3044
|
+
const currentEpoch = expectedEpoch ?? serverEpoch;
|
|
3045
|
+
const targetEpoch = currentEpoch + 1;
|
|
3046
|
+
const rotationId = `rot-${_uuidV4().replace(/-/g, '')}`;
|
|
3047
|
+
const info = await this._groupE2ee.rotateEpochTo(groupId, targetEpoch, memberAids, { rotationId });
|
|
3048
|
+
this._attachRotationId(info, rotationId);
|
|
3049
|
+
const discardGeneratedPending = async () => {
|
|
3050
|
+
try {
|
|
3051
|
+
await this._groupE2ee.discardPendingSecret(groupId, targetEpoch, rotationId);
|
|
3052
|
+
}
|
|
3053
|
+
catch (cleanupExc) {
|
|
3054
|
+
console.debug('清理本地 pending group key 失败: group=%s epoch=%d rotation=%s err=%s', groupId, targetEpoch, rotationId, formatCaughtError(cleanupExc));
|
|
3055
|
+
}
|
|
3056
|
+
};
|
|
3057
|
+
const rotateParams = {
|
|
3058
|
+
group_id: groupId,
|
|
3059
|
+
base_epoch: currentEpoch,
|
|
3060
|
+
target_epoch: targetEpoch,
|
|
3061
|
+
rotation_id: rotationId,
|
|
3062
|
+
reason: triggerId || expectedEpoch !== null ? 'membership_changed' : 'manual',
|
|
3063
|
+
key_commitment: String(info.commitment ?? ''),
|
|
3064
|
+
expected_members: memberAids,
|
|
3065
|
+
required_acks: [this._aid],
|
|
3066
|
+
lease_ms: GROUP_ROTATION_LEASE_MS,
|
|
3067
|
+
};
|
|
3068
|
+
const sigParams = await this._buildRotationSignature(groupId, currentEpoch, targetEpoch, rotateParams);
|
|
3069
|
+
Object.assign(rotateParams, sigParams);
|
|
3070
|
+
let rawBeginResult;
|
|
3071
|
+
try {
|
|
3072
|
+
rawBeginResult = await this.call('group.e2ee.begin_rotation', rotateParams);
|
|
3073
|
+
}
|
|
3074
|
+
catch (exc) {
|
|
3075
|
+
await discardGeneratedPending();
|
|
3076
|
+
throw exc;
|
|
3077
|
+
}
|
|
3078
|
+
const beginResult = isJsonObject(rawBeginResult) ? rawBeginResult : null;
|
|
3079
|
+
const beginRotationRaw = beginResult ? beginResult.rotation : null;
|
|
3080
|
+
const rotation = isJsonObject(beginRotationRaw) ? beginRotationRaw : null;
|
|
3081
|
+
if (!beginResult || beginResult.success !== true || !rotation) {
|
|
3082
|
+
if (rotation && !rotation.expired) {
|
|
3083
|
+
if (this._rotationExpectedMembersStale(rotation, memberAids)
|
|
3084
|
+
&& await this._abortGroupRotation(String(rotation.rotation_id ?? ''), 'membership_changed_during_rotation')) {
|
|
3085
|
+
this._scheduleGroupRotationRetry(groupId, {
|
|
3086
|
+
reason: 'membership_changed',
|
|
3087
|
+
triggerId,
|
|
3088
|
+
expectedEpoch,
|
|
3089
|
+
pending: null,
|
|
3090
|
+
});
|
|
3091
|
+
}
|
|
3092
|
+
else {
|
|
3093
|
+
this._scheduleGroupRotationRetry(groupId, {
|
|
3094
|
+
reason: 'membership_changed',
|
|
3095
|
+
triggerId,
|
|
3096
|
+
expectedEpoch,
|
|
3097
|
+
pending: rotation,
|
|
3098
|
+
});
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
else if (beginResult && beginResult.reason === 'expected_members_mismatch') {
|
|
3102
|
+
this._scheduleGroupRotationRetry(groupId, {
|
|
3103
|
+
reason: 'membership_changed',
|
|
3104
|
+
triggerId,
|
|
3105
|
+
expectedEpoch,
|
|
3106
|
+
pending: null,
|
|
3107
|
+
});
|
|
3108
|
+
}
|
|
3109
|
+
console.warn('group epoch begin failed; stop key distribution (group=%s, current_epoch=%d, returned=%s)', groupId, currentEpoch, JSON.stringify(beginResult));
|
|
3110
|
+
await discardGeneratedPending();
|
|
3111
|
+
return;
|
|
3112
|
+
}
|
|
3113
|
+
const activeRotationId = String(rotation.rotation_id ?? rotationId);
|
|
3114
|
+
const distributeResult = await this._distributeGroupEpochKey(info, activeRotationId);
|
|
3115
|
+
if (distributeResult.failed.length > 0) {
|
|
3116
|
+
console.warn('group epoch key distribution incomplete; abort rotation before retry (group=%s rotation=%s failed=%s)', groupId, activeRotationId, distributeResult.failed.join(','));
|
|
3117
|
+
await this._abortGroupRotation(activeRotationId, 'distribution_failed');
|
|
3118
|
+
this._scheduleGroupRotationRetry(groupId, {
|
|
3119
|
+
reason: 'membership_changed',
|
|
3120
|
+
triggerId,
|
|
3121
|
+
expectedEpoch,
|
|
3122
|
+
pending: null,
|
|
3123
|
+
});
|
|
3124
|
+
await discardGeneratedPending();
|
|
3125
|
+
return;
|
|
3126
|
+
}
|
|
3127
|
+
await this._heartbeatGroupRotation(activeRotationId);
|
|
3128
|
+
if (!await this._ackGroupRotationKey(activeRotationId, String(info.commitment ?? ''))) {
|
|
3129
|
+
console.warn('group epoch self ack failed; abort rotation before retry (group=%s rotation=%s)', groupId, activeRotationId);
|
|
3130
|
+
await this._abortGroupRotation(activeRotationId, 'self_ack_failed');
|
|
3131
|
+
this._scheduleGroupRotationRetry(groupId, {
|
|
3132
|
+
reason: 'membership_changed',
|
|
3133
|
+
triggerId,
|
|
3134
|
+
expectedEpoch,
|
|
3135
|
+
pending: null,
|
|
3136
|
+
});
|
|
3137
|
+
await discardGeneratedPending();
|
|
3138
|
+
return;
|
|
3139
|
+
}
|
|
3140
|
+
const commitResult = await this.call('group.e2ee.commit_rotation', { rotation_id: activeRotationId });
|
|
3141
|
+
if (!isJsonObject(commitResult) || commitResult.success !== true) {
|
|
3142
|
+
console.warn('group epoch commit failed (group=%s, rotation=%s, returned=%s)', groupId, activeRotationId, JSON.stringify(commitResult));
|
|
3143
|
+
this._scheduleGroupRotationRetry(groupId, {
|
|
3144
|
+
reason: 'membership_changed',
|
|
3145
|
+
triggerId,
|
|
3146
|
+
expectedEpoch,
|
|
3147
|
+
pending: isJsonObject(commitResult) && isJsonObject(commitResult.rotation) ? commitResult.rotation : rotation,
|
|
3148
|
+
});
|
|
3149
|
+
const returnedRotation = isJsonObject(commitResult) && isJsonObject(commitResult.rotation) ? commitResult.rotation : null;
|
|
3150
|
+
if (!(returnedRotation
|
|
3151
|
+
&& String(returnedRotation.rotation_id ?? '') === activeRotationId
|
|
3152
|
+
&& String(returnedRotation.status ?? '') === 'distributing')) {
|
|
3153
|
+
await discardGeneratedPending();
|
|
3154
|
+
}
|
|
3155
|
+
return;
|
|
3156
|
+
}
|
|
3157
|
+
const committedSecret = await this._groupE2ee.loadSecret(groupId, targetEpoch);
|
|
3158
|
+
if (committedSecret && this._aid) {
|
|
3159
|
+
const committedRotation = isJsonObject(commitResult.rotation)
|
|
3160
|
+
? commitResult.rotation
|
|
3161
|
+
: { rotation_id: activeRotationId, key_commitment: String(info.commitment ?? '') };
|
|
3162
|
+
if (this._groupSecretMatchesCommittedRotation(committedSecret, committedRotation)) {
|
|
3163
|
+
await storeGroupSecret(this._keystore, this._aid, groupId, targetEpoch, committedSecret.secret, committedSecret.commitment, committedSecret.member_aids.length > 0 ? committedSecret.member_aids : memberAids, committedSecret.epoch_chain);
|
|
3164
|
+
}
|
|
3165
|
+
else {
|
|
3166
|
+
console.warn('group epoch commit succeeded but local target key does not match committed rotation; keep pending blocked (group=%s rotation=%s epoch=%d)', groupId, activeRotationId, targetEpoch);
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
if (triggerId) {
|
|
3170
|
+
this._groupMembershipRotationDone.add(triggerId);
|
|
3171
|
+
if (this._groupMembershipRotationDone.size > 2000) {
|
|
3172
|
+
this._groupMembershipRotationDone = new Set(Array.from(this._groupMembershipRotationDone).slice(-1000));
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
catch (exc) {
|
|
3177
|
+
this._logE2eeError('rotate_epoch', groupId, '', exc);
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
/**
|
|
3181
|
+
* 将当前 group_secret 通过 P2P E2EE 分发给新成员。
|
|
3182
|
+
* 先拉服务端最新成员列表,更新本地,构建签名 manifest,再分发。
|
|
3183
|
+
*/
|
|
3184
|
+
async _distributeKeyToNewMember(groupId, newMemberAid) {
|
|
3185
|
+
try {
|
|
3186
|
+
const secretData = await this._groupE2ee.loadSecret(groupId);
|
|
3187
|
+
if (!secretData || !this._aid)
|
|
3188
|
+
return;
|
|
3189
|
+
// 拉服务端最新成员列表
|
|
3190
|
+
const membersResult = await this.call('group.get_members', { group_id: groupId });
|
|
3191
|
+
const memberList = isJsonObject(membersResult) && Array.isArray(membersResult.members)
|
|
3192
|
+
? membersResult.members
|
|
3193
|
+
: [];
|
|
3194
|
+
const memberAids = memberList.map(m => String(m.aid ?? ''));
|
|
3195
|
+
// 用最新成员列表更新本地当前 epoch
|
|
3196
|
+
const epoch = secretData.epoch;
|
|
3197
|
+
const commitment = await computeMembershipCommitment(memberAids, epoch, groupId, secretData.secret);
|
|
3198
|
+
await storeGroupSecret(this._keystore, this._aid, groupId, epoch, secretData.secret, commitment, memberAids);
|
|
3199
|
+
// 构建并签名 manifest
|
|
3200
|
+
let manifest = buildMembershipManifest(groupId, epoch, epoch, memberAids, {
|
|
3201
|
+
added: [newMemberAid],
|
|
3202
|
+
removed: [],
|
|
3203
|
+
initiatorAid: this._aid,
|
|
3204
|
+
});
|
|
3205
|
+
const identity = this._identity;
|
|
3206
|
+
if (identity && identity.private_key_pem) {
|
|
3207
|
+
manifest = await signMembershipManifest(manifest, identity.private_key_pem);
|
|
3208
|
+
}
|
|
3209
|
+
const distPayload = await buildKeyDistribution(groupId, epoch, secretData.secret, memberAids, this._aid, manifest);
|
|
3210
|
+
// 重试 3 次,间隔递增(1s, 2s)
|
|
3211
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
3212
|
+
try {
|
|
3213
|
+
await this.call('message.send', {
|
|
3214
|
+
to: newMemberAid,
|
|
3215
|
+
payload: distPayload,
|
|
3216
|
+
encrypt: true,
|
|
3217
|
+
persist_required: true,
|
|
3218
|
+
});
|
|
3219
|
+
break; // 成功则跳出重试循环
|
|
3220
|
+
}
|
|
3221
|
+
catch (sendExc) {
|
|
3222
|
+
if (attempt < 2) {
|
|
3223
|
+
await this._sleep((attempt + 1) * 1000);
|
|
3224
|
+
}
|
|
3225
|
+
else {
|
|
3226
|
+
throw sendExc;
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
}
|
|
3231
|
+
catch (exc) {
|
|
3232
|
+
this._logE2eeError('distribute_key', groupId, newMemberAid, exc);
|
|
3233
|
+
}
|
|
3234
|
+
}
|
|
3235
|
+
/** 构建 epoch 轮换签名参数(异步,使用 SubtleCrypto)。失败时抛错,调用方在 try/catch 中跳过轮换。 */
|
|
3236
|
+
async _buildRotationSignature(groupId, currentEpoch, newEpoch, source) {
|
|
3237
|
+
const identity = this._identity;
|
|
3238
|
+
if (!identity || !identity.private_key_pem) {
|
|
3239
|
+
throw new StateError('rotation signature requires local identity private key');
|
|
3240
|
+
}
|
|
3241
|
+
const aid = (identity.aid ?? '');
|
|
3242
|
+
const ts = String(Math.floor(Date.now() / 1000));
|
|
3243
|
+
let signData;
|
|
3244
|
+
if (source) {
|
|
3245
|
+
const listField = (key) => {
|
|
3246
|
+
const raw = source[key];
|
|
3247
|
+
return Array.isArray(raw)
|
|
3248
|
+
? raw.map(item => String(item ?? '').trim()).filter(Boolean).sort()
|
|
3249
|
+
: [];
|
|
3250
|
+
};
|
|
3251
|
+
signData = new TextEncoder().encode(stableStringify({
|
|
3252
|
+
version: 'v2',
|
|
3253
|
+
group_id: groupId,
|
|
3254
|
+
base_epoch: Math.trunc(currentEpoch),
|
|
3255
|
+
target_epoch: Math.trunc(newEpoch),
|
|
3256
|
+
aid,
|
|
3257
|
+
rotation_timestamp: ts,
|
|
3258
|
+
rotation_id: String(source.rotation_id ?? source.new_rotation_id ?? ''),
|
|
3259
|
+
reason: String(source.reason ?? ''),
|
|
3260
|
+
key_commitment: String(source.key_commitment ?? ''),
|
|
3261
|
+
manifest_hash: String(source.manifest_hash ?? ''),
|
|
3262
|
+
epoch_chain: String(source.epoch_chain ?? ''),
|
|
3263
|
+
expected_members: listField('expected_members'),
|
|
3264
|
+
required_acks: listField('required_acks'),
|
|
3265
|
+
}));
|
|
3266
|
+
}
|
|
3267
|
+
else {
|
|
3268
|
+
signData = new TextEncoder().encode(`${groupId}|${currentEpoch}|${newEpoch}|${aid}|${ts}`);
|
|
3269
|
+
}
|
|
3270
|
+
const pkcs8 = pemToArrayBuffer(identity.private_key_pem);
|
|
3271
|
+
const cryptoKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);
|
|
3272
|
+
const sigP1363 = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, cryptoKey, toBufferSource(signData));
|
|
3273
|
+
// P1363 → DER 格式(使用顶部静态导入的 p1363ToDer)
|
|
3274
|
+
const sigDer = p1363ToDer(new Uint8Array(sigP1363));
|
|
3275
|
+
const result = {
|
|
3276
|
+
rotation_signature: uint8ToBase64(sigDer),
|
|
3277
|
+
rotation_timestamp: ts,
|
|
3278
|
+
};
|
|
3279
|
+
if (source)
|
|
3280
|
+
result.rotation_sig_version = 'v2';
|
|
3281
|
+
return result;
|
|
3282
|
+
}
|
|
3283
|
+
// ── 内部:连接 ────────────────────────────────────
|
|
3284
|
+
async _connectOnce(params, allowReauth) {
|
|
3285
|
+
const gatewayUrl = this._resolveGateway(params);
|
|
3286
|
+
this._gatewayUrl = gatewayUrl;
|
|
3287
|
+
this._slotId = String(params.slot_id ?? '');
|
|
3288
|
+
this._connectDeliveryMode = { ...(params.delivery_mode ?? this._connectDeliveryMode) };
|
|
3289
|
+
this._auth.setInstanceContext({ deviceId: this._deviceId, slotId: this._slotId });
|
|
3290
|
+
this._state = 'connecting';
|
|
3291
|
+
// 前置 restore:在 _transport.connect 启动 reader 之前完成,
|
|
3292
|
+
// 避免 reader 把积压 push 交给空 tracker 的 handler,触发 S2 历史 gap 误补拉。
|
|
3293
|
+
this._refreshSeqTrackerContext();
|
|
3294
|
+
await this._restoreSeqTrackerState();
|
|
3295
|
+
const challenge = await this._transport.connect(gatewayUrl);
|
|
3296
|
+
this._state = 'authenticating';
|
|
3297
|
+
if (allowReauth) {
|
|
3298
|
+
const authContext = await this._auth.connectSession(this._transport, challenge, gatewayUrl, {
|
|
3299
|
+
accessToken: params.access_token,
|
|
3300
|
+
deviceId: this._deviceId,
|
|
3301
|
+
slotId: this._slotId,
|
|
3302
|
+
deliveryMode: this._connectDeliveryMode,
|
|
3303
|
+
});
|
|
3304
|
+
if (isJsonObject(authContext)) {
|
|
3305
|
+
const auth = authContext;
|
|
3306
|
+
const identity = auth.identity;
|
|
3307
|
+
if (identity && isJsonObject(identity)) {
|
|
3308
|
+
this._identity = identity;
|
|
3309
|
+
this._aid = String(identity.aid ?? this._aid ?? '');
|
|
3310
|
+
if (this._sessionParams) {
|
|
3311
|
+
this._sessionParams.access_token = String(auth.token ?? params.access_token ?? '');
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
else {
|
|
3317
|
+
await this._auth.initializeWithToken(this._transport, challenge, String(params.access_token), {
|
|
3318
|
+
deviceId: this._deviceId,
|
|
3319
|
+
slotId: this._slotId,
|
|
3320
|
+
deliveryMode: this._connectDeliveryMode,
|
|
3321
|
+
});
|
|
3322
|
+
await this._syncIdentityAfterConnect(String(params.access_token));
|
|
3323
|
+
}
|
|
3324
|
+
this._state = 'connected';
|
|
3325
|
+
await this._dispatcher.publish('connection.state', {
|
|
3326
|
+
state: this._state,
|
|
3327
|
+
gateway: gatewayUrl,
|
|
3328
|
+
});
|
|
3329
|
+
// auth 阶段 aid 可能被 identity 覆盖;若 context 发生变化,重新 refresh + restore。
|
|
3330
|
+
if (this._seqTrackerContext !== this._currentSeqTrackerContext()) {
|
|
3331
|
+
this._refreshSeqTrackerContext();
|
|
3332
|
+
await this._restoreSeqTrackerState();
|
|
3333
|
+
}
|
|
3334
|
+
this._startBackgroundTasks();
|
|
3335
|
+
// 上线后自动上传 prekey
|
|
3336
|
+
try {
|
|
3337
|
+
await this._uploadPrekey();
|
|
3338
|
+
}
|
|
3339
|
+
catch (exc) {
|
|
3340
|
+
console.warn('prekey 上传失败:', exc);
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
_resolveGateway(params) {
|
|
3344
|
+
const topology = isJsonObject(params.topology) ? params.topology : null;
|
|
3345
|
+
if (topology) {
|
|
3346
|
+
const mode = String(topology.mode ?? 'gateway');
|
|
3347
|
+
if (mode === 'peer') {
|
|
3348
|
+
throw new ValidationError('peer topology is not implemented in the Browser SDK');
|
|
3349
|
+
}
|
|
3350
|
+
if (mode === 'relay') {
|
|
3351
|
+
throw new ValidationError('relay topology is not implemented in the Browser SDK');
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
const gateway = String(params.gateway ?? this._gatewayUrl ?? '');
|
|
3355
|
+
if (!gateway)
|
|
3356
|
+
throw new StateError('missing gateway in connect params');
|
|
3357
|
+
return gateway;
|
|
3358
|
+
}
|
|
3359
|
+
async _syncIdentityAfterConnect(accessToken) {
|
|
3360
|
+
let identity = null;
|
|
3361
|
+
try {
|
|
3362
|
+
identity = await this._auth.loadIdentityOrNone(this._aid ?? undefined);
|
|
3363
|
+
}
|
|
3364
|
+
catch { /* 忽略 */ }
|
|
3365
|
+
if (!identity) {
|
|
3366
|
+
this._identity = null;
|
|
3367
|
+
return;
|
|
3368
|
+
}
|
|
3369
|
+
identity.access_token = accessToken;
|
|
3370
|
+
this._identity = identity;
|
|
3371
|
+
this._aid = String(identity.aid ?? this._aid ?? '');
|
|
3372
|
+
if (identity.aid) {
|
|
3373
|
+
const persistIdentity = this._auth._persistIdentity;
|
|
3374
|
+
if (typeof persistIdentity === 'function') {
|
|
3375
|
+
await persistIdentity.call(this._auth, identity);
|
|
3376
|
+
}
|
|
3377
|
+
else {
|
|
3378
|
+
await this._keystore.saveIdentity(String(identity.aid), identity);
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
// ── 内部:参数处理 ────────────────────────────────
|
|
3383
|
+
_normalizeConnectParams(params) {
|
|
3384
|
+
const request = { ...params };
|
|
3385
|
+
const accessToken = String(request.access_token ?? '');
|
|
3386
|
+
if (!accessToken)
|
|
3387
|
+
throw new StateError('connect requires non-empty access_token');
|
|
3388
|
+
const gateway = String(request.gateway ?? this._gatewayUrl ?? '');
|
|
3389
|
+
if (!gateway)
|
|
3390
|
+
throw new StateError('connect requires non-empty gateway');
|
|
3391
|
+
request.access_token = accessToken;
|
|
3392
|
+
request.gateway = gateway;
|
|
3393
|
+
request.device_id = this._deviceId;
|
|
3394
|
+
request.slot_id = normalizeInstanceId(request.slot_id ?? this._slotId, 'slot_id', { allowEmpty: true });
|
|
3395
|
+
let deliveryModeRaw = request.delivery_mode;
|
|
3396
|
+
if (deliveryModeRaw == null) {
|
|
3397
|
+
deliveryModeRaw = { ...this._defaultConnectDeliveryMode };
|
|
3398
|
+
}
|
|
3399
|
+
else if (!isJsonObject(deliveryModeRaw)) {
|
|
3400
|
+
deliveryModeRaw = { mode: deliveryModeRaw };
|
|
3401
|
+
}
|
|
3402
|
+
else {
|
|
3403
|
+
deliveryModeRaw = { ...deliveryModeRaw };
|
|
3404
|
+
}
|
|
3405
|
+
if ('queue_routing' in request) {
|
|
3406
|
+
deliveryModeRaw.routing = request.queue_routing;
|
|
3407
|
+
}
|
|
3408
|
+
if ('affinity_ttl_ms' in request) {
|
|
3409
|
+
deliveryModeRaw.affinity_ttl_ms = request.affinity_ttl_ms;
|
|
3410
|
+
}
|
|
3411
|
+
request.delivery_mode = normalizeDeliveryModeConfig(deliveryModeRaw);
|
|
3412
|
+
if (request.topology !== undefined && !isJsonObject(request.topology)) {
|
|
3413
|
+
throw new ValidationError('topology must be an object');
|
|
3414
|
+
}
|
|
3415
|
+
if (request.retry !== undefined && !isJsonObject(request.retry)) {
|
|
3416
|
+
throw new ValidationError('retry must be an object');
|
|
3417
|
+
}
|
|
3418
|
+
if (request.timeouts !== undefined && !isJsonObject(request.timeouts)) {
|
|
3419
|
+
throw new ValidationError('timeouts must be an object');
|
|
3420
|
+
}
|
|
3421
|
+
return request;
|
|
3422
|
+
}
|
|
3423
|
+
_buildSessionOptions(params) {
|
|
3424
|
+
const options = {
|
|
3425
|
+
auto_reconnect: DEFAULT_SESSION_OPTIONS.auto_reconnect,
|
|
3426
|
+
heartbeat_interval: DEFAULT_SESSION_OPTIONS.heartbeat_interval,
|
|
3427
|
+
token_refresh_before: DEFAULT_SESSION_OPTIONS.token_refresh_before,
|
|
3428
|
+
retry: { ...DEFAULT_SESSION_OPTIONS.retry },
|
|
3429
|
+
timeouts: { ...DEFAULT_SESSION_OPTIONS.timeouts },
|
|
3430
|
+
};
|
|
3431
|
+
if ('auto_reconnect' in params) {
|
|
3432
|
+
options.auto_reconnect = Boolean(params.auto_reconnect);
|
|
3433
|
+
}
|
|
3434
|
+
if ('heartbeat_interval' in params) {
|
|
3435
|
+
options.heartbeat_interval = Number(params.heartbeat_interval);
|
|
3436
|
+
}
|
|
3437
|
+
if ('token_refresh_before' in params) {
|
|
3438
|
+
options.token_refresh_before = Number(params.token_refresh_before);
|
|
3439
|
+
}
|
|
3440
|
+
if (isJsonObject(params.retry)) {
|
|
3441
|
+
Object.assign(options.retry, params.retry);
|
|
3442
|
+
}
|
|
3443
|
+
if (isJsonObject(params.timeouts)) {
|
|
3444
|
+
Object.assign(options.timeouts, params.timeouts);
|
|
3445
|
+
}
|
|
3446
|
+
return options;
|
|
3447
|
+
}
|
|
3448
|
+
// ── 内部:后台任务 ────────────────────────────────
|
|
3449
|
+
_startBackgroundTasks() {
|
|
3450
|
+
this._startHeartbeat();
|
|
3451
|
+
this._startTokenRefresh();
|
|
3452
|
+
this._startPrekeyRefresh();
|
|
3453
|
+
this._startGroupEpochTasks();
|
|
3454
|
+
// 上线/重连后一次性补齐群消息和群事件
|
|
3455
|
+
this._safeAsync(this._syncAllGroupsOnce());
|
|
3456
|
+
}
|
|
3457
|
+
_stopBackgroundTasks() {
|
|
3458
|
+
if (this._heartbeatTimer !== null) {
|
|
3459
|
+
clearInterval(this._heartbeatTimer);
|
|
3460
|
+
this._heartbeatTimer = null;
|
|
3461
|
+
}
|
|
3462
|
+
if (this._tokenRefreshTimer !== null) {
|
|
3463
|
+
clearTimeout(this._tokenRefreshTimer);
|
|
3464
|
+
this._tokenRefreshTimer = null;
|
|
3465
|
+
}
|
|
3466
|
+
if (this._prekeyRefreshTimer !== null) {
|
|
3467
|
+
clearTimeout(this._prekeyRefreshTimer);
|
|
3468
|
+
this._prekeyRefreshTimer = null;
|
|
3469
|
+
}
|
|
3470
|
+
if (this._groupEpochCleanupTimer !== null) {
|
|
3471
|
+
clearInterval(this._groupEpochCleanupTimer);
|
|
3472
|
+
this._groupEpochCleanupTimer = null;
|
|
3473
|
+
}
|
|
3474
|
+
if (this._groupEpochRotateTimer !== null) {
|
|
3475
|
+
clearInterval(this._groupEpochRotateTimer);
|
|
3476
|
+
this._groupEpochRotateTimer = null;
|
|
3477
|
+
}
|
|
3478
|
+
if (this._cacheCleanupTimer !== null) {
|
|
3479
|
+
clearInterval(this._cacheCleanupTimer);
|
|
3480
|
+
this._cacheCleanupTimer = null;
|
|
3481
|
+
}
|
|
3482
|
+
for (const timer of this._groupEpochRotationRetryTimers.values()) {
|
|
3483
|
+
clearTimeout(timer);
|
|
3484
|
+
}
|
|
3485
|
+
this._groupEpochRotationRetryTimers.clear();
|
|
3486
|
+
}
|
|
3487
|
+
/** 心跳定时器 */
|
|
3488
|
+
_startHeartbeat() {
|
|
3489
|
+
if (this._heartbeatTimer !== null)
|
|
3490
|
+
return;
|
|
3491
|
+
const interval = this._sessionOptions.heartbeat_interval * 1000;
|
|
3492
|
+
if (interval <= 0)
|
|
3493
|
+
return;
|
|
3494
|
+
// M25: 把连续失败阈值从 3 次收窄到 2 次。既能容忍一次网络抖动/GC 暂停,
|
|
3495
|
+
// 又把半开连接的检测延迟从 3 个心跳周期降到 2 个,避免 RPC 长时间挂起。
|
|
3496
|
+
// 真正的 socket 死亡由 ws.on('close') 立即触发 _handleTransportDisconnect,
|
|
3497
|
+
// 不依赖此心跳路径。
|
|
3498
|
+
let consecutiveFailures = 0;
|
|
3499
|
+
const maxFailures = 2;
|
|
3500
|
+
this._heartbeatTimer = setInterval(async () => {
|
|
3501
|
+
if (this._state !== 'connected' || this._closing)
|
|
3502
|
+
return;
|
|
3503
|
+
try {
|
|
3504
|
+
await this._transport.call('meta.ping', {});
|
|
3505
|
+
consecutiveFailures = 0;
|
|
3506
|
+
}
|
|
3507
|
+
catch (exc) {
|
|
3508
|
+
consecutiveFailures++;
|
|
3509
|
+
console.warn(`心跳失败 (${consecutiveFailures}/${maxFailures}):`, exc);
|
|
3510
|
+
this._dispatcher.publish('connection.error', { error: formatCaughtError(exc) });
|
|
3511
|
+
if (consecutiveFailures >= maxFailures) {
|
|
3512
|
+
console.warn(`连续 ${maxFailures} 次心跳失败,触发断线重连`);
|
|
3513
|
+
this._handleTransportDisconnect(exc instanceof Error ? exc : new Error(String(exc)));
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
}, interval);
|
|
3517
|
+
}
|
|
3518
|
+
/** Token 刷新定时器 */
|
|
3519
|
+
_startTokenRefresh() {
|
|
3520
|
+
if (this._tokenRefreshTimer !== null)
|
|
3521
|
+
return;
|
|
3522
|
+
const scheduleRefresh = () => {
|
|
3523
|
+
if (this._closing)
|
|
3524
|
+
return;
|
|
3525
|
+
const lead = this._sessionOptions.token_refresh_before;
|
|
3526
|
+
const minimumDelay = 1000;
|
|
3527
|
+
if (this._state !== 'connected' || !this._gatewayUrl) {
|
|
3528
|
+
// 非连接状态下使用指数退避,避免 1s 轮询浪费 CPU
|
|
3529
|
+
this._tokenDisconnectedRetries++;
|
|
3530
|
+
const backoff = Math.min(minimumDelay * Math.pow(2, this._tokenDisconnectedRetries), 60_000);
|
|
3531
|
+
this._tokenRefreshTimer = globalThis.setTimeout(scheduleRefresh, backoff);
|
|
3532
|
+
return;
|
|
3533
|
+
}
|
|
3534
|
+
// 连接恢复后重置退避计数器
|
|
3535
|
+
this._tokenDisconnectedRetries = 0;
|
|
3536
|
+
let identity = this._identity;
|
|
3537
|
+
if (!identity) {
|
|
3538
|
+
this._tokenRefreshTimer = globalThis.setTimeout(scheduleRefresh, minimumDelay);
|
|
3539
|
+
return;
|
|
3540
|
+
}
|
|
3541
|
+
const expiresAt = this._auth.getAccessTokenExpiry(identity);
|
|
3542
|
+
if (expiresAt === null) {
|
|
3543
|
+
this._tokenRefreshTimer = globalThis.setTimeout(scheduleRefresh, minimumDelay);
|
|
3544
|
+
return;
|
|
3545
|
+
}
|
|
3546
|
+
const delay = Math.max((expiresAt - lead) * 1000 - Date.now(), minimumDelay);
|
|
3547
|
+
this._tokenRefreshTimer = globalThis.setTimeout(async () => {
|
|
3548
|
+
if (this._state !== 'connected' || !this._gatewayUrl || this._closing) {
|
|
3549
|
+
scheduleRefresh();
|
|
3550
|
+
return;
|
|
3551
|
+
}
|
|
3552
|
+
try {
|
|
3553
|
+
identity = await this._auth.refreshCachedTokens(this._gatewayUrl, identity);
|
|
3554
|
+
this._identity = identity;
|
|
3555
|
+
if (this._sessionParams && identity.access_token) {
|
|
3556
|
+
this._sessionParams.access_token = identity.access_token;
|
|
3557
|
+
}
|
|
3558
|
+
await this._dispatcher.publish('token.refreshed', {
|
|
3559
|
+
aid: identity.aid,
|
|
3560
|
+
expires_at: identity.access_token_expires_at,
|
|
3561
|
+
});
|
|
3562
|
+
this._tokenRefreshFailures = 0;
|
|
3563
|
+
}
|
|
3564
|
+
catch (exc) {
|
|
3565
|
+
if (exc instanceof AuthError) {
|
|
3566
|
+
this._tokenRefreshFailures++;
|
|
3567
|
+
if (this._tokenRefreshFailures >= 3) {
|
|
3568
|
+
console.warn(`token 刷新连续失败 ${this._tokenRefreshFailures} 次,停止刷新循环并触发重连`);
|
|
3569
|
+
this._dispatcher.publish('token.refresh_exhausted', {
|
|
3570
|
+
aid: this._identity?.aid ?? null,
|
|
3571
|
+
consecutive_failures: this._tokenRefreshFailures,
|
|
3572
|
+
last_error: String(exc),
|
|
3573
|
+
});
|
|
3574
|
+
this._tokenRefreshFailures = 0;
|
|
3575
|
+
this._handleTransportDisconnect(new Error('token refresh exhausted, triggering reconnect'));
|
|
3576
|
+
return;
|
|
3577
|
+
}
|
|
3578
|
+
console.warn(`token 刷新失败 (${this._tokenRefreshFailures}/3),下次重试:`, exc);
|
|
3579
|
+
}
|
|
3580
|
+
else {
|
|
3581
|
+
this._dispatcher.publish('connection.error', { error: formatCaughtError(exc) });
|
|
3582
|
+
}
|
|
3583
|
+
}
|
|
3584
|
+
scheduleRefresh();
|
|
3585
|
+
}, delay);
|
|
3586
|
+
};
|
|
3587
|
+
scheduleRefresh();
|
|
3588
|
+
}
|
|
3589
|
+
/** Prekey 轮换定时器:定期检查本地 prekey 数量,不足时自动补充上传 */
|
|
3590
|
+
_startPrekeyRefresh() {
|
|
3591
|
+
if (this._prekeyRefreshTimer !== null)
|
|
3592
|
+
return;
|
|
3593
|
+
const PREKEY_CHECK_INTERVAL = 3600_000; // 1 小时
|
|
3594
|
+
const PREKEY_LOW_THRESHOLD = 5; // 低于此数量则补充
|
|
3595
|
+
const PREKEY_UPLOAD_COUNT = 10; // 每次补充上传的数量
|
|
3596
|
+
const check = async () => {
|
|
3597
|
+
try {
|
|
3598
|
+
if (this._state !== 'connected' || !this._e2ee)
|
|
3599
|
+
return;
|
|
3600
|
+
const aid = this._identity?.aid;
|
|
3601
|
+
if (!aid)
|
|
3602
|
+
return;
|
|
3603
|
+
// 从 keystore 加载本地 prekey 并计数
|
|
3604
|
+
let remaining = 0;
|
|
3605
|
+
try {
|
|
3606
|
+
const deviceId = String(this._deviceId ?? '').trim();
|
|
3607
|
+
let prekeys = {};
|
|
3608
|
+
if (typeof this._keystore.loadE2EEPrekeys === 'function') {
|
|
3609
|
+
prekeys = (await this._keystore.loadE2EEPrekeys(aid, deviceId)) ?? {};
|
|
3610
|
+
}
|
|
3611
|
+
remaining = Object.keys(prekeys).length;
|
|
3612
|
+
}
|
|
3613
|
+
catch {
|
|
3614
|
+
// keystore 读取失败时保守地触发补充
|
|
3615
|
+
remaining = 0;
|
|
3616
|
+
}
|
|
3617
|
+
// prekey 不足时批量生成并上传
|
|
3618
|
+
if (remaining < PREKEY_LOW_THRESHOLD) {
|
|
3619
|
+
const uploadCount = PREKEY_UPLOAD_COUNT - remaining;
|
|
3620
|
+
for (let i = 0; i < uploadCount; i++) {
|
|
3621
|
+
await this._uploadPrekey();
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
catch (exc) {
|
|
3626
|
+
console.warn('[aun_core] prekey 定时刷新失败:', exc);
|
|
3627
|
+
}
|
|
3628
|
+
// 仍处于连接状态时安排下一次检查
|
|
3629
|
+
if (this._state === 'connected') {
|
|
3630
|
+
this._prekeyRefreshTimer = globalThis.setTimeout(check, PREKEY_CHECK_INTERVAL);
|
|
3631
|
+
}
|
|
3632
|
+
};
|
|
3633
|
+
// 首次检查延迟 1 小时后启动
|
|
3634
|
+
this._prekeyRefreshTimer = globalThis.setTimeout(check, PREKEY_CHECK_INTERVAL);
|
|
3635
|
+
}
|
|
3636
|
+
_extractConsumedPrekeyId(message) {
|
|
3637
|
+
if (!message)
|
|
3638
|
+
return '';
|
|
3639
|
+
const e2ee = message.e2ee;
|
|
3640
|
+
if (!e2ee)
|
|
3641
|
+
return '';
|
|
3642
|
+
if (e2ee.encryption_mode !== 'prekey_ecdh_v2')
|
|
3643
|
+
return '';
|
|
3644
|
+
return String(e2ee.prekey_id ?? '').trim();
|
|
3645
|
+
}
|
|
3646
|
+
_validateMessageRecipient(toAid) {
|
|
3647
|
+
if (isGroupServiceAid(toAid)) {
|
|
3648
|
+
throw new ValidationError('message.send receiver cannot be group.{issuer}; use group.send instead');
|
|
3649
|
+
}
|
|
3650
|
+
}
|
|
3651
|
+
_validateOutboundCall(method, params) {
|
|
3652
|
+
if (method === 'message.send') {
|
|
3653
|
+
this._validateMessageRecipient(params.to);
|
|
3654
|
+
if ('persist' in params) {
|
|
3655
|
+
throw new ValidationError("message.send no longer accepts 'persist'; configure delivery_mode during connect");
|
|
3656
|
+
}
|
|
3657
|
+
if ('delivery_mode' in params || 'queue_routing' in params || 'affinity_ttl_ms' in params) {
|
|
3658
|
+
throw new ValidationError('message.send does not accept delivery_mode; configure delivery_mode during connect');
|
|
3659
|
+
}
|
|
3660
|
+
}
|
|
3661
|
+
if (method === 'group.send') {
|
|
3662
|
+
if ('persist' in params) {
|
|
3663
|
+
throw new ValidationError("group.send does not accept 'persist'; group messages are always fanout");
|
|
3664
|
+
}
|
|
3665
|
+
if ('delivery_mode' in params || 'queue_routing' in params || 'affinity_ttl_ms' in params) {
|
|
3666
|
+
throw new ValidationError('group.send does not accept delivery_mode; group messages are always fanout');
|
|
3667
|
+
}
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
3670
|
+
_currentMessageDeliveryMode() {
|
|
3671
|
+
return { ...this._connectDeliveryMode };
|
|
3672
|
+
}
|
|
3673
|
+
_injectMessageCursorContext(method, params) {
|
|
3674
|
+
if (method !== 'message.pull' && method !== 'message.ack') {
|
|
3675
|
+
return;
|
|
3676
|
+
}
|
|
3677
|
+
if ('device_id' in params && String(params.device_id ?? '').trim() !== this._deviceId) {
|
|
3678
|
+
throw new ValidationError('message.pull/message.ack device_id must match the current client instance');
|
|
3679
|
+
}
|
|
3680
|
+
const slotId = normalizeInstanceId(params.slot_id ?? this._slotId, 'slot_id', { allowEmpty: true });
|
|
3681
|
+
if (slotId !== this._slotId) {
|
|
3682
|
+
throw new ValidationError('message.pull/message.ack slot_id must match the current client instance');
|
|
3683
|
+
}
|
|
3684
|
+
params.device_id = this._deviceId;
|
|
3685
|
+
params.slot_id = this._slotId;
|
|
3686
|
+
}
|
|
3687
|
+
_schedulePrekeyReplenishIfConsumed(message) {
|
|
3688
|
+
const prekeyId = this._extractConsumedPrekeyId(message);
|
|
3689
|
+
if (!prekeyId || this._state !== 'connected')
|
|
3690
|
+
return;
|
|
3691
|
+
if (this._prekeyReplenished.has(prekeyId))
|
|
3692
|
+
return;
|
|
3693
|
+
// 同一时刻只允许一个 put_prekey inflight
|
|
3694
|
+
if (this._prekeyReplenishInflight.size > 0)
|
|
3695
|
+
return;
|
|
3696
|
+
this._prekeyReplenishInflight.add(prekeyId);
|
|
3697
|
+
this._safeAsync((async () => {
|
|
3698
|
+
try {
|
|
3699
|
+
await this._uploadPrekey();
|
|
3700
|
+
this._prekeyReplenished.add(prekeyId);
|
|
3701
|
+
}
|
|
3702
|
+
catch (exc) {
|
|
3703
|
+
console.warn(`消费 prekey ${prekeyId} 后补充 current prekey 失败:`, exc);
|
|
3704
|
+
}
|
|
3705
|
+
finally {
|
|
3706
|
+
this._prekeyReplenishInflight.delete(prekeyId);
|
|
3707
|
+
}
|
|
3708
|
+
})());
|
|
3709
|
+
}
|
|
3710
|
+
/** 群组 epoch 后台任务 */
|
|
3711
|
+
_startGroupEpochTasks() {
|
|
3712
|
+
// 群组 E2EE 是必备能力,始终执行 epoch 清理
|
|
3713
|
+
// 旧 epoch 清理(每小时)
|
|
3714
|
+
if (this._groupEpochCleanupTimer === null) {
|
|
3715
|
+
this._groupEpochCleanupTimer = setInterval(async () => {
|
|
3716
|
+
if (this._state !== 'connected' || this._closing || !this._aid)
|
|
3717
|
+
return;
|
|
3718
|
+
try {
|
|
3719
|
+
const groupIds = typeof this._keystore.listGroupSecretIds === 'function'
|
|
3720
|
+
? await this._keystore.listGroupSecretIds(this._aid)
|
|
3721
|
+
: [];
|
|
3722
|
+
const retention = this.configModel.oldEpochRetentionSeconds;
|
|
3723
|
+
for (const gid of groupIds) {
|
|
3724
|
+
await this._groupE2ee.cleanup(gid, retention);
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
catch (exc) {
|
|
3728
|
+
console.warn('epoch 清理失败:', exc);
|
|
3729
|
+
}
|
|
3730
|
+
}, 3600 * 1000);
|
|
3731
|
+
}
|
|
3732
|
+
// 定时 epoch 轮换
|
|
3733
|
+
const rotateInterval = this.configModel.epochAutoRotateInterval;
|
|
3734
|
+
if (rotateInterval > 0 && this._groupEpochRotateTimer === null) {
|
|
3735
|
+
this._groupEpochRotateTimer = setInterval(async () => {
|
|
3736
|
+
if (this._state !== 'connected' || this._closing || !this._aid)
|
|
3737
|
+
return;
|
|
3738
|
+
try {
|
|
3739
|
+
const groupIds = typeof this._keystore.listGroupSecretIds === 'function'
|
|
3740
|
+
? await this._keystore.listGroupSecretIds(this._aid)
|
|
3741
|
+
: [];
|
|
3742
|
+
for (const gid of groupIds) {
|
|
3743
|
+
await this._maybeLeadRotateGroupEpoch(gid);
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3746
|
+
catch (exc) {
|
|
3747
|
+
console.warn('epoch 定时轮换失败:', exc);
|
|
3748
|
+
}
|
|
3749
|
+
}, rotateInterval * 1000);
|
|
3750
|
+
}
|
|
3751
|
+
// 内存缓存定时清理(每小时扫描过期条目)
|
|
3752
|
+
if (this._cacheCleanupTimer === null) {
|
|
3753
|
+
this._cacheCleanupTimer = setInterval(() => {
|
|
3754
|
+
const nowSec = Date.now() / 1000;
|
|
3755
|
+
for (const [k, v] of this._certCache) {
|
|
3756
|
+
if (nowSec >= v.refreshAfter)
|
|
3757
|
+
this._certCache.delete(k);
|
|
3758
|
+
}
|
|
3759
|
+
for (const [k, v] of this._peerPrekeysCache) {
|
|
3760
|
+
if (nowSec >= v.expireAt)
|
|
3761
|
+
this._peerPrekeysCache.delete(k);
|
|
3762
|
+
}
|
|
3763
|
+
if (this._gapFillDone.size > 10000) {
|
|
3764
|
+
const arr = [...this._gapFillDone];
|
|
3765
|
+
this._gapFillDone = new Set(arr.slice(arr.length - 5000));
|
|
3766
|
+
}
|
|
3767
|
+
this._e2ee.cleanExpiredCaches();
|
|
3768
|
+
this._groupE2ee.cleanExpiredCaches();
|
|
3769
|
+
this._auth.cleanExpiredCaches();
|
|
3770
|
+
}, 3600_000);
|
|
3771
|
+
}
|
|
3772
|
+
}
|
|
3773
|
+
// ── 内部:断线重连 ────────────────────────────────
|
|
3774
|
+
/** 不重连 close code 集合:认证失败/权限错误/被踢等,重连无意义 */
|
|
3775
|
+
static _NO_RECONNECT_CODES = new Set([4001, 4003, 4008, 4009, 4010, 4011]);
|
|
3776
|
+
/** 处理服务端主动断开通知 event/gateway.disconnect */
|
|
3777
|
+
_onGatewayDisconnect(data) {
|
|
3778
|
+
const code = data?.code;
|
|
3779
|
+
const reason = data?.reason ?? '';
|
|
3780
|
+
console.warn(`[aun_core] 服务端主动断开: code=${code}, reason=${reason}`);
|
|
3781
|
+
this._serverKicked = true;
|
|
3782
|
+
}
|
|
3783
|
+
async _handleTransportDisconnect(error, closeCode) {
|
|
3784
|
+
if (this._closing || this._state === 'closed')
|
|
3785
|
+
return;
|
|
3786
|
+
this._state = 'disconnected';
|
|
3787
|
+
// 先停止后台任务,避免心跳/token刷新在重连期间继续触发
|
|
3788
|
+
this._stopBackgroundTasks();
|
|
3789
|
+
await this._dispatcher.publish('connection.state', {
|
|
3790
|
+
state: this._state,
|
|
3791
|
+
error,
|
|
3792
|
+
});
|
|
3793
|
+
if (!this._sessionOptions.auto_reconnect)
|
|
3794
|
+
return;
|
|
3795
|
+
if (this._reconnectActive)
|
|
3796
|
+
return;
|
|
3797
|
+
// 不重连 close code(认证失败/权限错误/被踢等)或服务端通知断开:抑制重连
|
|
3798
|
+
if (this._serverKicked || (closeCode !== undefined && AUNClient._NO_RECONNECT_CODES.has(closeCode))) {
|
|
3799
|
+
this._state = 'terminal_failed';
|
|
3800
|
+
const reason = this._serverKicked ? 'server kicked' : `close code ${closeCode}`;
|
|
3801
|
+
console.warn(`[aun_core] 抑制自动重连: ${reason}`);
|
|
3802
|
+
await this._dispatcher.publish('connection.state', {
|
|
3803
|
+
state: this._state, error, reason,
|
|
3804
|
+
});
|
|
3805
|
+
return;
|
|
3806
|
+
}
|
|
3807
|
+
// 1000 = 正常关闭, 1006 = 网络异常断开(无 close frame),其他 code = 服务端主动关闭
|
|
3808
|
+
const serverInitiated = closeCode !== undefined && closeCode !== 1000 && closeCode !== 1006;
|
|
3809
|
+
this._reconnectActive = true;
|
|
3810
|
+
this._reconnectAbort = new AbortController();
|
|
3811
|
+
this._safeAsync(this._reconnectLoop(serverInitiated));
|
|
3812
|
+
}
|
|
3813
|
+
/** 指数退避 + 固定上限抖动重连循环(默认无限重试,仅在不可重试错误或 close() 或 max_attempts 耗尽时终止) */
|
|
3814
|
+
async _reconnectLoop(serverInitiated = false) {
|
|
3815
|
+
const retry = { ...this._sessionOptions.retry };
|
|
3816
|
+
const maxBaseDelay = clampReconnectDelaySeconds(retry.max_delay, RECONNECT_MAX_BASE_DELAY_SECONDS);
|
|
3817
|
+
// M25: max_attempts=0 表示无限重试(与 Go/Python 对齐)
|
|
3818
|
+
const maxAttemptsRaw = Number(retry.max_attempts ?? 0);
|
|
3819
|
+
const maxAttempts = Number.isFinite(maxAttemptsRaw) && maxAttemptsRaw > 0 ? Math.floor(maxAttemptsRaw) : 0;
|
|
3820
|
+
// 服务端主动关闭时从 16s 起跳,避免重连风暴;网络断开从 initial_delay 起跳
|
|
3821
|
+
let delay = clampReconnectDelaySeconds(serverInitiated ? 16.0 : retry.initial_delay, serverInitiated ? 16.0 : 1.0, maxBaseDelay);
|
|
3822
|
+
for (let attempt = 1; !this._reconnectAbort?.signal.aborted; attempt++) {
|
|
3823
|
+
// R1 fix: max_attempts 检查在循环顶部,覆盖所有路径(含 health-fail)
|
|
3824
|
+
if (maxAttempts > 0 && attempt > maxAttempts) {
|
|
3825
|
+
this._state = 'terminal_failed';
|
|
3826
|
+
this._reconnectActive = false;
|
|
3827
|
+
this._reconnectAbort = null;
|
|
3828
|
+
await this._dispatcher.publish('connection.state', {
|
|
3829
|
+
state: this._state,
|
|
3830
|
+
attempt: attempt - 1,
|
|
3831
|
+
reason: 'max_attempts_exhausted',
|
|
3832
|
+
});
|
|
3833
|
+
return;
|
|
3834
|
+
}
|
|
3835
|
+
this._state = 'reconnecting';
|
|
3836
|
+
await this._dispatcher.publish('connection.state', {
|
|
3837
|
+
state: this._state,
|
|
3838
|
+
attempt,
|
|
3839
|
+
});
|
|
3840
|
+
try {
|
|
3841
|
+
await this._sleep(reconnectSleepDelaySeconds(delay, maxBaseDelay) * 1000);
|
|
3842
|
+
if (this._reconnectAbort?.signal.aborted) {
|
|
3843
|
+
this._reconnectActive = false;
|
|
3844
|
+
return;
|
|
3845
|
+
}
|
|
3846
|
+
// 重连前先 GET /health 探测,不健康则跳过本轮
|
|
3847
|
+
if (this._gatewayUrl) {
|
|
3848
|
+
const healthy = await this._discovery.checkHealth(this._gatewayUrl, 5000);
|
|
3849
|
+
if (!healthy) {
|
|
3850
|
+
delay = Math.min(delay * 2, maxBaseDelay);
|
|
3851
|
+
continue;
|
|
3852
|
+
}
|
|
3853
|
+
}
|
|
3854
|
+
await this._transport.close();
|
|
3855
|
+
if (!this._sessionParams) {
|
|
3856
|
+
throw new StateError('missing connect params for reconnect');
|
|
3857
|
+
}
|
|
3858
|
+
await this._connectOnce(this._sessionParams, true);
|
|
3859
|
+
this._reconnectActive = false;
|
|
3860
|
+
this._reconnectAbort = null;
|
|
3861
|
+
return;
|
|
3862
|
+
}
|
|
3863
|
+
catch (exc) {
|
|
3864
|
+
await this._dispatcher.publish('connection.error', {
|
|
3865
|
+
error: formatCaughtError(exc),
|
|
3866
|
+
attempt,
|
|
3867
|
+
});
|
|
3868
|
+
if (!this._shouldRetryReconnect(exc)) {
|
|
3869
|
+
this._state = 'terminal_failed';
|
|
3870
|
+
this._reconnectActive = false;
|
|
3871
|
+
this._reconnectAbort = null;
|
|
3872
|
+
await this._dispatcher.publish('connection.state', {
|
|
3873
|
+
state: this._state,
|
|
3874
|
+
error: formatCaughtError(exc),
|
|
3875
|
+
attempt,
|
|
3876
|
+
});
|
|
3877
|
+
return;
|
|
3878
|
+
}
|
|
3879
|
+
delay = Math.min(delay * 2, maxBaseDelay);
|
|
3880
|
+
}
|
|
3881
|
+
}
|
|
3882
|
+
this._reconnectActive = false;
|
|
3883
|
+
this._reconnectAbort = null;
|
|
3884
|
+
}
|
|
3885
|
+
/** 判断是否应重试重连 */
|
|
3886
|
+
_shouldRetryReconnect(error) {
|
|
3887
|
+
if (error instanceof AuthError) {
|
|
3888
|
+
const message = String(error.message ?? '').toLowerCase();
|
|
3889
|
+
if (message.includes('aid_login1_failed') || message.includes('aid_login2_failed')) {
|
|
3890
|
+
return true;
|
|
3891
|
+
}
|
|
3892
|
+
return false;
|
|
3893
|
+
}
|
|
3894
|
+
if (error instanceof PermissionError
|
|
3895
|
+
|| error instanceof ValidationError || error instanceof StateError) {
|
|
3896
|
+
return false;
|
|
3897
|
+
}
|
|
3898
|
+
if (error instanceof ConnectionError)
|
|
3899
|
+
return true;
|
|
3900
|
+
if (error instanceof AUNError)
|
|
3901
|
+
return error.retryable;
|
|
3902
|
+
// 网络相关错误默认重试
|
|
3903
|
+
return true;
|
|
3904
|
+
}
|
|
3905
|
+
// ── 内部:工具方法 ────────────────────────────────
|
|
3906
|
+
/** 从 keystore 恢复 SeqTracker 状态(真正可 await,确保在 transport.connect 前完成) */
|
|
3907
|
+
async _restoreSeqTrackerState() {
|
|
3908
|
+
if (!this._aid)
|
|
3909
|
+
return;
|
|
3910
|
+
const context = this._seqTrackerContext;
|
|
3911
|
+
if (!context)
|
|
3912
|
+
return;
|
|
3913
|
+
const aid = this._aid;
|
|
3914
|
+
const deviceId = this._deviceId;
|
|
3915
|
+
const slotId = this._slotId;
|
|
3916
|
+
try {
|
|
3917
|
+
// 优先从 seq_tracker 表按行读取
|
|
3918
|
+
const loadAll = this._keystore.loadAllSeqs?.bind(this._keystore);
|
|
3919
|
+
if (typeof loadAll === 'function') {
|
|
3920
|
+
const state = await loadAll(aid, deviceId, slotId);
|
|
3921
|
+
if (this._seqTrackerContext !== context)
|
|
3922
|
+
return;
|
|
3923
|
+
if (state && typeof state === 'object' && Object.keys(state).length > 0) {
|
|
3924
|
+
this._seqTracker.restoreState(state);
|
|
3925
|
+
}
|
|
3926
|
+
return;
|
|
3927
|
+
}
|
|
3928
|
+
// fallback: 从旧 instance_state JSON blob 恢复
|
|
3929
|
+
const loader = this._keystore.loadInstanceState?.bind(this._keystore);
|
|
3930
|
+
if (typeof loader !== 'function')
|
|
3931
|
+
return;
|
|
3932
|
+
const stateHolder = await loader(aid, deviceId, slotId);
|
|
3933
|
+
if (this._seqTrackerContext !== context)
|
|
3934
|
+
return;
|
|
3935
|
+
if (stateHolder && typeof stateHolder === 'object') {
|
|
3936
|
+
const state = stateHolder.seq_tracker_state;
|
|
3937
|
+
if (isJsonObject(state)) {
|
|
3938
|
+
this._seqTracker.restoreState(state);
|
|
3939
|
+
}
|
|
3940
|
+
}
|
|
3941
|
+
}
|
|
3942
|
+
catch (exc) {
|
|
3943
|
+
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
3944
|
+
phase: 'restore',
|
|
3945
|
+
aid,
|
|
3946
|
+
device_id: deviceId,
|
|
3947
|
+
slot_id: slotId,
|
|
3948
|
+
error: String(exc),
|
|
3949
|
+
}).catch(() => { });
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
_currentSeqTrackerContext() {
|
|
3953
|
+
if (!this._aid)
|
|
3954
|
+
return null;
|
|
3955
|
+
return JSON.stringify([this._aid, this._deviceId, this._slotId]);
|
|
3956
|
+
}
|
|
3957
|
+
_resetSeqTrackingState() {
|
|
3958
|
+
this._seqTracker = new SeqTracker();
|
|
3959
|
+
this._seqTrackerContext = null;
|
|
3960
|
+
this._gapFillDone.clear();
|
|
3961
|
+
this._pushedSeqs.clear();
|
|
3962
|
+
this._groupSynced.clear();
|
|
3963
|
+
this._p2pSynced = false;
|
|
3964
|
+
}
|
|
3965
|
+
_refreshSeqTrackerContext() {
|
|
3966
|
+
const nextContext = this._currentSeqTrackerContext();
|
|
3967
|
+
if (nextContext === this._seqTrackerContext)
|
|
3968
|
+
return;
|
|
3969
|
+
this._seqTracker = new SeqTracker();
|
|
3970
|
+
this._gapFillDone.clear();
|
|
3971
|
+
this._pushedSeqs.clear();
|
|
3972
|
+
this._groupSynced.clear();
|
|
3973
|
+
this._p2pSynced = false;
|
|
3974
|
+
this._seqTrackerContext = nextContext;
|
|
3975
|
+
}
|
|
3976
|
+
/** 将 SeqTracker 状态保存到 keystore */
|
|
3977
|
+
_saveSeqTrackerState() {
|
|
3978
|
+
if (!this._aid)
|
|
3979
|
+
return;
|
|
3980
|
+
const state = this._seqTracker.exportState();
|
|
3981
|
+
if (Object.keys(state).length === 0)
|
|
3982
|
+
return;
|
|
3983
|
+
try {
|
|
3984
|
+
// 优先按行写入 seq_tracker 表
|
|
3985
|
+
const saveFn = this._keystore.saveSeq?.bind(this._keystore);
|
|
3986
|
+
if (typeof saveFn === 'function') {
|
|
3987
|
+
for (const [ns, seq] of Object.entries(state)) {
|
|
3988
|
+
saveFn(this._aid, this._deviceId, this._slotId, ns, seq).catch((exc) => {
|
|
3989
|
+
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
3990
|
+
phase: 'save',
|
|
3991
|
+
aid: this._aid,
|
|
3992
|
+
device_id: this._deviceId,
|
|
3993
|
+
slot_id: this._slotId,
|
|
3994
|
+
error: String(exc),
|
|
3995
|
+
}).catch(() => { });
|
|
3996
|
+
});
|
|
3997
|
+
}
|
|
3998
|
+
return;
|
|
3999
|
+
}
|
|
4000
|
+
// fallback: 旧版 updateInstanceState JSON blob
|
|
4001
|
+
if (typeof this._keystore.updateInstanceState === 'function') {
|
|
4002
|
+
this._keystore.updateInstanceState(this._aid, this._deviceId, this._slotId, (current) => {
|
|
4003
|
+
current.seq_tracker_state = state;
|
|
4004
|
+
return current;
|
|
4005
|
+
}).catch((exc) => {
|
|
4006
|
+
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
4007
|
+
phase: 'save',
|
|
4008
|
+
aid: this._aid,
|
|
4009
|
+
device_id: this._deviceId,
|
|
4010
|
+
slot_id: this._slotId,
|
|
4011
|
+
error: String(exc),
|
|
4012
|
+
}).catch(() => { });
|
|
4013
|
+
});
|
|
4014
|
+
}
|
|
4015
|
+
}
|
|
4016
|
+
catch (exc) {
|
|
4017
|
+
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
4018
|
+
phase: 'save',
|
|
4019
|
+
aid: this._aid,
|
|
4020
|
+
device_id: this._deviceId,
|
|
4021
|
+
slot_id: this._slotId,
|
|
4022
|
+
error: String(exc),
|
|
4023
|
+
}).catch(() => { });
|
|
4024
|
+
}
|
|
4025
|
+
}
|
|
4026
|
+
/** 发布 E2EE 编排错误事件 */
|
|
4027
|
+
_logE2eeError(stage, groupId, aid, exc) {
|
|
4028
|
+
try {
|
|
4029
|
+
this._dispatcher.publish('e2ee.orchestration_error', {
|
|
4030
|
+
stage,
|
|
4031
|
+
group_id: groupId,
|
|
4032
|
+
aid,
|
|
4033
|
+
error: String(exc),
|
|
4034
|
+
});
|
|
4035
|
+
}
|
|
4036
|
+
catch { /* 日志本身不应阻断主流程 */ }
|
|
4037
|
+
}
|
|
4038
|
+
/** 安全执行异步操作(不阻塞调用方,错误打 warning 便于排障) */
|
|
4039
|
+
_safeAsync(promise) {
|
|
4040
|
+
promise.catch((exc) => {
|
|
4041
|
+
console.warn('后台任务异常:', exc);
|
|
4042
|
+
});
|
|
4043
|
+
}
|
|
4044
|
+
/** 可取消的 sleep */
|
|
4045
|
+
_sleep(ms) {
|
|
4046
|
+
return new Promise((resolve) => {
|
|
4047
|
+
globalThis.setTimeout(resolve, ms);
|
|
4048
|
+
});
|
|
4049
|
+
}
|
|
4050
|
+
}
|
|
4051
|
+
// ── 内部工具 ────────────────────────────────────────
|
|
4052
|
+
/** 生成 UUID v4 */
|
|
4053
|
+
function _uuidV4() {
|
|
4054
|
+
const bytes = new Uint8Array(16);
|
|
4055
|
+
crypto.getRandomValues(bytes);
|
|
4056
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
4057
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
4058
|
+
const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
4059
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
4060
|
+
}
|
|
4061
|
+
//# sourceMappingURL=client.js.map
|