@agentunion/fastaun-browser 0.2.14 → 0.2.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +4 -1
- package/dist/auth.js.map +1 -1
- package/dist/client.d.ts +47 -8
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1039 -205
- package/dist/client.js.map +1 -1
- package/dist/e2ee-group.d.ts +51 -0
- package/dist/e2ee-group.d.ts.map +1 -1
- package/dist/e2ee-group.js +383 -30
- package/dist/e2ee-group.js.map +1 -1
- package/dist/e2ee.d.ts +23 -0
- package/dist/e2ee.d.ts.map +1 -1
- package/dist/e2ee.js +270 -33
- package/dist/e2ee.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/keystore/index.d.ts +24 -0
- package/dist/keystore/index.d.ts.map +1 -1
- package/dist/keystore/indexeddb.d.ts +22 -1
- package/dist/keystore/indexeddb.d.ts.map +1 -1
- package/dist/keystore/indexeddb.js +139 -3
- package/dist/keystore/indexeddb.js.map +1 -1
- package/dist/namespaces/auth.d.ts +19 -0
- package/dist/namespaces/auth.d.ts.map +1 -1
- package/dist/namespaces/auth.js +237 -0
- package/dist/namespaces/auth.js.map +1 -1
- package/dist/namespaces/meta.d.ts +106 -0
- package/dist/namespaces/meta.d.ts.map +1 -0
- package/dist/namespaces/meta.js +498 -0
- package/dist/namespaces/meta.js.map +1 -0
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +1 -0
- package/dist/transport.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -12,9 +12,10 @@ import { AuthFlow } from './auth.js';
|
|
|
12
12
|
import { SeqTracker } from './seq-tracker.js';
|
|
13
13
|
import { AuthNamespace } from './namespaces/auth.js';
|
|
14
14
|
import { CustodyNamespace } from './namespaces/custody.js';
|
|
15
|
+
import { MetaNamespace } from './namespaces/meta.js';
|
|
15
16
|
import { CryptoProvider, uint8ToBase64, base64ToUint8, pemToArrayBuffer, p1363ToDer, toBufferSource } from './crypto.js';
|
|
16
17
|
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 { GroupE2EEManager, computeMembershipCommitment, computeStateHash, storeGroupSecret, storeGroupSecretEpoch, buildKeyDistribution, buildKeyRequest, buildMembershipManifest, signMembershipManifest, verifyEpochChain, } from './e2ee-group.js';
|
|
18
19
|
import { IndexedDBKeyStore } from './keystore/indexeddb.js';
|
|
19
20
|
import { AUNError, AuthError, ConnectionError, E2EEError, PermissionError, StateError, ValidationError, } from './errors.js';
|
|
20
21
|
import { isJsonObject, } from './types.js';
|
|
@@ -66,11 +67,16 @@ const SIGNED_METHODS = new Set([
|
|
|
66
67
|
'group.resources.delete', 'group.resources.request_add',
|
|
67
68
|
'group.resources.direct_add', 'group.resources.approve_request',
|
|
68
69
|
'group.resources.reject_request',
|
|
70
|
+
'group.commit_state',
|
|
71
|
+
'group.e2ee.begin_rotation', 'group.e2ee.commit_rotation',
|
|
72
|
+
'group.e2ee.abort_rotation',
|
|
73
|
+
'group.ban', 'group.unban',
|
|
74
|
+
'group.dissolve', 'group.suspend', 'group.resume',
|
|
69
75
|
]);
|
|
70
76
|
const DEFAULT_SESSION_OPTIONS = {
|
|
71
77
|
auto_reconnect: true,
|
|
72
78
|
heartbeat_interval: 30.0,
|
|
73
|
-
token_refresh_before:
|
|
79
|
+
token_refresh_before: 1800.0,
|
|
74
80
|
retry: {
|
|
75
81
|
initial_delay: 1.0,
|
|
76
82
|
max_delay: 64.0,
|
|
@@ -85,6 +91,7 @@ const DEFAULT_SESSION_OPTIONS = {
|
|
|
85
91
|
};
|
|
86
92
|
const RECONNECT_MIN_BASE_DELAY_SECONDS = 1.0;
|
|
87
93
|
const RECONNECT_MAX_BASE_DELAY_SECONDS = 64.0;
|
|
94
|
+
const TOKEN_REFRESH_CHECK_INTERVAL_MS = 30_000;
|
|
88
95
|
const GROUP_ROTATION_LEASE_MS = 120_000;
|
|
89
96
|
const GROUP_ROTATION_RETRY_MAX_DELAY_MS = 300_000;
|
|
90
97
|
const PENDING_DECRYPT_LIMIT = 100;
|
|
@@ -111,7 +118,8 @@ function reconnectSleepDelaySeconds(baseDelay, maxBaseDelay) {
|
|
|
111
118
|
return baseDelay + Math.random() * maxBaseDelay;
|
|
112
119
|
}
|
|
113
120
|
/** 对端证书缓存 TTL(秒) */
|
|
114
|
-
const PEER_CERT_CACHE_TTL =
|
|
121
|
+
const PEER_CERT_CACHE_TTL = 3600;
|
|
122
|
+
const PEER_PREKEYS_CACHE_TTL = 3600;
|
|
115
123
|
/**
|
|
116
124
|
* 将 WebSocket URL 转为对应的 HTTP URL
|
|
117
125
|
*/
|
|
@@ -169,6 +177,7 @@ function isGroupServiceAid(value) {
|
|
|
169
177
|
const [name, ...issuerParts] = text.split('.');
|
|
170
178
|
return name === 'group' && issuerParts.join('.').length > 0;
|
|
171
179
|
}
|
|
180
|
+
const PREKEY_FALLBACK_DEVICE_ID = 'aun_device_id';
|
|
172
181
|
function isPeerPrekeyMaterial(value) {
|
|
173
182
|
if (!isJsonObject(value))
|
|
174
183
|
return false;
|
|
@@ -186,6 +195,65 @@ function isPeerPrekeyResponse(value) {
|
|
|
186
195
|
return false;
|
|
187
196
|
return candidate.prekey === undefined || isPeerPrekeyMaterial(candidate.prekey);
|
|
188
197
|
}
|
|
198
|
+
function normalizePeerPrekeys(prekeys) {
|
|
199
|
+
const normalized = [];
|
|
200
|
+
for (const item of prekeys) {
|
|
201
|
+
if (!isPeerPrekeyMaterial(item))
|
|
202
|
+
continue;
|
|
203
|
+
const prekeyId = item.prekey_id.trim();
|
|
204
|
+
const publicKey = item.public_key.trim();
|
|
205
|
+
const signature = item.signature.trim();
|
|
206
|
+
if (!prekeyId || !publicKey || !signature)
|
|
207
|
+
continue;
|
|
208
|
+
const deviceId = String(item.device_id ?? '').trim();
|
|
209
|
+
const certFingerprint = String(item.cert_fingerprint ?? '').trim().toLowerCase();
|
|
210
|
+
const candidate = {
|
|
211
|
+
...item,
|
|
212
|
+
prekey_id: prekeyId,
|
|
213
|
+
public_key: publicKey,
|
|
214
|
+
signature,
|
|
215
|
+
device_id: deviceId,
|
|
216
|
+
};
|
|
217
|
+
if (certFingerprint) {
|
|
218
|
+
candidate.cert_fingerprint = certFingerprint;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
delete candidate.cert_fingerprint;
|
|
222
|
+
}
|
|
223
|
+
normalized.push(candidate);
|
|
224
|
+
}
|
|
225
|
+
if (normalized.length === 0)
|
|
226
|
+
return [];
|
|
227
|
+
if (normalized.length === 1) {
|
|
228
|
+
if (!String(normalized[0].device_id ?? '').trim()) {
|
|
229
|
+
normalized[0].device_id = PREKEY_FALLBACK_DEVICE_ID;
|
|
230
|
+
}
|
|
231
|
+
return normalized;
|
|
232
|
+
}
|
|
233
|
+
const seen = new Set();
|
|
234
|
+
const filtered = [];
|
|
235
|
+
for (const item of normalized) {
|
|
236
|
+
const deviceId = String(item.device_id ?? '').trim();
|
|
237
|
+
if (!deviceId || deviceId === PREKEY_FALLBACK_DEVICE_ID || seen.has(deviceId))
|
|
238
|
+
continue;
|
|
239
|
+
seen.add(deviceId);
|
|
240
|
+
filtered.push(item);
|
|
241
|
+
}
|
|
242
|
+
return filtered;
|
|
243
|
+
}
|
|
244
|
+
/** 判断加密失败是否由过期的对端证书或 prekey 引起,可通过刷新缓存重试 */
|
|
245
|
+
function isRetryablePeerMaterialError(error) {
|
|
246
|
+
const localCode = String(error?.localCode ?? error?.code ?? '').trim();
|
|
247
|
+
if (localCode === 'PEER_CERT_FINGERPRINT_MISMATCH'
|
|
248
|
+
|| localCode === 'PREKEY_CERT_FINGERPRINT_MISMATCH'
|
|
249
|
+
|| localCode === 'PREKEY_SIGNATURE_VERIFY_FAILED') {
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
const message = error instanceof Error ? error.message : String(error ?? '');
|
|
253
|
+
return message.includes('peer cert fingerprint mismatch for ')
|
|
254
|
+
|| message.includes('prekey cert fingerprint mismatch')
|
|
255
|
+
|| message.includes('prekey 签名验证失败');
|
|
256
|
+
}
|
|
189
257
|
function formatCaughtError(error) {
|
|
190
258
|
return error instanceof Error ? error : String(error);
|
|
191
259
|
}
|
|
@@ -267,6 +335,8 @@ export class AUNClient {
|
|
|
267
335
|
auth;
|
|
268
336
|
/** AID 托管命名空间 */
|
|
269
337
|
custody;
|
|
338
|
+
/** 元数据命名空间(心跳、状态、信任根管理) */
|
|
339
|
+
meta;
|
|
270
340
|
// E2EE 编排状态(内存缓存)
|
|
271
341
|
_certCache = new Map();
|
|
272
342
|
_prekeyReplenishInflight = new Set();
|
|
@@ -275,8 +345,6 @@ export class AUNClient {
|
|
|
275
345
|
// 后台任务 handle(浏览器 setInterval/setTimeout)
|
|
276
346
|
_heartbeatTimer = null;
|
|
277
347
|
_tokenRefreshTimer = null;
|
|
278
|
-
/** 非连接状态下 token 刷新的退避计数器 */
|
|
279
|
-
_tokenDisconnectedRetries = 0;
|
|
280
348
|
_tokenRefreshFailures = 0;
|
|
281
349
|
_prekeyRefreshTimer = null;
|
|
282
350
|
_groupEpochCleanupTimer = null;
|
|
@@ -295,6 +363,8 @@ export class AUNClient {
|
|
|
295
363
|
_groupEpochRotationInflight = new Set();
|
|
296
364
|
_groupEpochRecoveryInflight = new Map();
|
|
297
365
|
_groupMembershipRotationDone = new Set();
|
|
366
|
+
/** 群密钥 backfill 去重:已完成/进行中的 key 集合,防止重复分发 */
|
|
367
|
+
_groupMemberKeyBackfillDone = new Set();
|
|
298
368
|
_groupEpochRotationRetryTimers = new Map();
|
|
299
369
|
/** Lazy group sync:首次发送群消息前自动拉取历史 */
|
|
300
370
|
_groupSynced = new Set();
|
|
@@ -350,6 +420,7 @@ export class AUNClient {
|
|
|
350
420
|
});
|
|
351
421
|
this.auth = new AuthNamespace(this);
|
|
352
422
|
this.custody = new CustodyNamespace(this);
|
|
423
|
+
this.meta = new MetaNamespace(this);
|
|
353
424
|
// 内部订阅:推送消息自动解密后 re-publish 给用户
|
|
354
425
|
this._dispatcher.subscribe('_raw.message.received', (data) => {
|
|
355
426
|
this._onRawMessageReceived(data);
|
|
@@ -362,6 +433,10 @@ export class AUNClient {
|
|
|
362
433
|
this._dispatcher.subscribe('_raw.group.changed', (data) => {
|
|
363
434
|
this._onRawGroupChanged(data);
|
|
364
435
|
});
|
|
436
|
+
// 群组状态提交事件:验证 state_hash 链并更新本地存储
|
|
437
|
+
this._dispatcher.subscribe('_raw.group.state_committed', (data) => {
|
|
438
|
+
this._safeAsync(this._onGroupStateCommitted(data));
|
|
439
|
+
});
|
|
365
440
|
// 其他事件直接透传
|
|
366
441
|
for (const evt of ['message.recalled', 'message.ack', 'storage.object_changed']) {
|
|
367
442
|
this._dispatcher.subscribe(`_raw.${evt}`, (data) => {
|
|
@@ -414,13 +489,23 @@ export class AUNClient {
|
|
|
414
489
|
if (this._state !== 'idle' && this._state !== 'closed' && this._state !== 'disconnected') {
|
|
415
490
|
throw new StateError(`connect not allowed in state ${this._state}`);
|
|
416
491
|
}
|
|
492
|
+
this._state = 'connecting';
|
|
417
493
|
const params = { ...auth, ...options };
|
|
418
494
|
const normalized = this._normalizeConnectParams(params);
|
|
419
495
|
this._sessionParams = normalized;
|
|
420
496
|
this._sessionOptions = this._buildSessionOptions(normalized);
|
|
421
497
|
this._transport.setTimeout(this._sessionOptions.timeouts.call);
|
|
422
498
|
this._closing = false;
|
|
423
|
-
|
|
499
|
+
try {
|
|
500
|
+
await this._connectOnce(normalized, false);
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
// 连接失败时回退状态,允许重试
|
|
504
|
+
if (this._state === 'connecting' || this._state === 'authenticating') {
|
|
505
|
+
this._state = 'disconnected';
|
|
506
|
+
}
|
|
507
|
+
throw err;
|
|
508
|
+
}
|
|
424
509
|
}
|
|
425
510
|
/** 断开连接但保留本地状态,可再次 connect */
|
|
426
511
|
async disconnect() {
|
|
@@ -533,6 +618,8 @@ export class AUNClient {
|
|
|
533
618
|
if (encrypt) {
|
|
534
619
|
return this._sendEncrypted(p);
|
|
535
620
|
}
|
|
621
|
+
delete p.protected_headers;
|
|
622
|
+
delete p.headers;
|
|
536
623
|
}
|
|
537
624
|
// 自动加密:group.send 默认加密(encrypt 默认 true)
|
|
538
625
|
if (method === 'group.send') {
|
|
@@ -541,6 +628,8 @@ export class AUNClient {
|
|
|
541
628
|
if (encrypt) {
|
|
542
629
|
return this._sendGroupEncrypted(p);
|
|
543
630
|
}
|
|
631
|
+
delete p.protected_headers;
|
|
632
|
+
delete p.headers;
|
|
544
633
|
}
|
|
545
634
|
if (method === 'group.thought.put') {
|
|
546
635
|
const encrypt = p.encrypt !== undefined ? p.encrypt : true;
|
|
@@ -686,8 +775,11 @@ export class AUNClient {
|
|
|
686
775
|
const groupId = this._extractGroupIdFromResult(result) || String(p.group_id ?? '');
|
|
687
776
|
if (groupId && this._membershipRotationChanged(method, result)) {
|
|
688
777
|
const expectedEpoch = this._membershipRotationExpectedEpoch(result);
|
|
778
|
+
// 自加入方法(request_join/use_invite_code)需要 allowMember=true,
|
|
779
|
+
// 因为新成员角色是 member,必须允许 member 参与 leader 选举。
|
|
780
|
+
const allowMember = method === 'group.request_join' || method === 'group.use_invite_code';
|
|
689
781
|
// P0-12: await rotation 完成(带超时兜底),确保后续 group.send 使用新 epoch
|
|
690
|
-
const rotationPromise = this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch);
|
|
782
|
+
const rotationPromise = this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch, allowMember);
|
|
691
783
|
const timeoutPromise = new Promise((resolve) => globalThis.setTimeout(resolve, 5000));
|
|
692
784
|
await Promise.race([rotationPromise, timeoutPromise]).catch((exc) => console.warn('membership RPC epoch rotation fallback failed:', exc));
|
|
693
785
|
}
|
|
@@ -696,13 +788,13 @@ export class AUNClient {
|
|
|
696
788
|
}
|
|
697
789
|
// ── 便利方法 ──────────────────────────────────────
|
|
698
790
|
async ping(params) {
|
|
699
|
-
return this.
|
|
791
|
+
return this.meta.ping(params);
|
|
700
792
|
}
|
|
701
793
|
async status(params) {
|
|
702
|
-
return this.
|
|
794
|
+
return this.meta.status(params);
|
|
703
795
|
}
|
|
704
796
|
async trustRoots(params) {
|
|
705
|
-
return this.
|
|
797
|
+
return this.meta.trustRoots(params);
|
|
706
798
|
}
|
|
707
799
|
// ── 事件 ──────────────────────────────────────────
|
|
708
800
|
/**
|
|
@@ -1027,6 +1119,11 @@ export class AUNClient {
|
|
|
1027
1119
|
// 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
|
|
1028
1120
|
if (et === 'group.message_created')
|
|
1029
1121
|
continue;
|
|
1122
|
+
// 验签:有 client_signature 就验(与实时事件路径对齐)
|
|
1123
|
+
const cs = evt.client_signature;
|
|
1124
|
+
if (cs && typeof cs === 'object') {
|
|
1125
|
+
evt._verified = await this._verifyEventSignature(evt, cs);
|
|
1126
|
+
}
|
|
1030
1127
|
// group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
|
|
1031
1128
|
await this._dispatcher.publish('group.changed', evt);
|
|
1032
1129
|
}
|
|
@@ -1222,51 +1319,6 @@ export class AUNClient {
|
|
|
1222
1319
|
await this._drainOrderedMessages(ns);
|
|
1223
1320
|
return true;
|
|
1224
1321
|
}
|
|
1225
|
-
/**
|
|
1226
|
-
* 上线/重连后一次性同步所有已加入群:
|
|
1227
|
-
* 1. 有 epoch key 的群 → 补消息 + 补事件
|
|
1228
|
-
* 2. 无 epoch key 的群 → 主动向 owner 请求密钥恢复 + 补事件
|
|
1229
|
-
*/
|
|
1230
|
-
async _syncAllGroupsOnce() {
|
|
1231
|
-
try {
|
|
1232
|
-
const result = await this.call('group.list_my', {});
|
|
1233
|
-
if (!isJsonObject(result))
|
|
1234
|
-
return;
|
|
1235
|
-
const items = result.items;
|
|
1236
|
-
if (!Array.isArray(items))
|
|
1237
|
-
return;
|
|
1238
|
-
for (const g of items) {
|
|
1239
|
-
if (isJsonObject(g)) {
|
|
1240
|
-
const gid = (g.group_id ?? '');
|
|
1241
|
-
if (gid) {
|
|
1242
|
-
const hasSecret = await this._groupE2ee.hasSecret(gid);
|
|
1243
|
-
if (!hasSecret) {
|
|
1244
|
-
// 没有 epoch key → 主动向 owner 请求密钥恢复(与 Python 对齐)
|
|
1245
|
-
const ownerAid = (g.owner_aid ?? '');
|
|
1246
|
-
if (ownerAid && ownerAid !== this._aid) {
|
|
1247
|
-
await this._requestGroupKeyFrom(gid, ownerAid);
|
|
1248
|
-
}
|
|
1249
|
-
else {
|
|
1250
|
-
console.debug(`[aun_core] 群 ${gid} 无 epoch key 且无法确定 owner,等待推送触发恢复`);
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
else {
|
|
1254
|
-
// 有 epoch key → 补消息
|
|
1255
|
-
await this._fillGroupGap(gid);
|
|
1256
|
-
}
|
|
1257
|
-
// 所有群都补事件(事件不加密)
|
|
1258
|
-
await this._fillGroupEventGap(gid);
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
|
-
catch (exc) {
|
|
1264
|
-
console.warn('[aun_core] 上线群组同步失败,群消息可能不完整:', exc);
|
|
1265
|
-
this._dispatcher.publish('group.sync_failed', {
|
|
1266
|
-
error: exc instanceof Error ? exc.message : String(exc),
|
|
1267
|
-
}).catch(() => { });
|
|
1268
|
-
}
|
|
1269
|
-
}
|
|
1270
1322
|
/** 主动向指定成员请求群组密钥(用于重连时无 epoch key 的群)(与 Python 对齐) */
|
|
1271
1323
|
async _requestGroupKeyFrom(groupId, targetAid, epoch = 0) {
|
|
1272
1324
|
try {
|
|
@@ -1442,14 +1494,35 @@ export class AUNClient {
|
|
|
1442
1494
|
}
|
|
1443
1495
|
}
|
|
1444
1496
|
}
|
|
1497
|
+
// 成员加入:按 action 区分策略
|
|
1498
|
+
// - member_added / join_approved(私密群/审批群):admin 必然在线,立即轮换
|
|
1499
|
+
// - joined / invite_code_used(开放群/邀请码群):所有在线成员延迟轮换,新成员自己延迟更长
|
|
1445
1500
|
if (['member_added', 'joined', 'join_approved', 'invite_code_used'].includes(String(d.action ?? ''))) {
|
|
1446
1501
|
if (groupId) {
|
|
1502
|
+
const action = String(d.action ?? '');
|
|
1447
1503
|
const expectedEpoch = this._membershipRotationExpectedEpoch(d);
|
|
1448
|
-
|
|
1449
|
-
|
|
1504
|
+
const joinedAids = this._joinedMemberAidsFromPayload(d);
|
|
1505
|
+
const isSelfJoining = joinedAids.includes(this._aid ?? '') && (action === 'joined' || action === 'invite_code_used');
|
|
1506
|
+
if (isSelfJoining || (action === 'joined' || action === 'invite_code_used')) {
|
|
1507
|
+
// open/invite_code 群:所有在线成员都参与延迟轮换
|
|
1508
|
+
// 新成员自己延迟更长,优先让其他在线成员先轮换
|
|
1509
|
+
const triggerId = this._membershipRotationTriggerId(groupId, d);
|
|
1510
|
+
if (!isSelfJoining) {
|
|
1511
|
+
this._safeAsync(this._maybeBackfillKeyToJoinedMember(groupId, d, triggerId));
|
|
1512
|
+
}
|
|
1513
|
+
if (expectedEpoch !== null) {
|
|
1514
|
+
const delay = isSelfJoining ? AUNClient._SELF_JOIN_ROTATION_DELAY_MS : undefined;
|
|
1515
|
+
this._safeAsync(this._delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, true, delay));
|
|
1516
|
+
}
|
|
1450
1517
|
}
|
|
1451
1518
|
else {
|
|
1452
|
-
|
|
1519
|
+
if (expectedEpoch === null) {
|
|
1520
|
+
const triggerId = this._membershipRotationTriggerId(groupId, d);
|
|
1521
|
+
this._safeAsync(this._maybeBackfillKeyToJoinedMember(groupId, d, triggerId));
|
|
1522
|
+
}
|
|
1523
|
+
else {
|
|
1524
|
+
this._safeAsync(this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch));
|
|
1525
|
+
}
|
|
1453
1526
|
}
|
|
1454
1527
|
}
|
|
1455
1528
|
}
|
|
@@ -1465,6 +1538,107 @@ export class AUNClient {
|
|
|
1465
1538
|
await this._dispatcher.publish('group.changed', data);
|
|
1466
1539
|
}
|
|
1467
1540
|
}
|
|
1541
|
+
/**
|
|
1542
|
+
* 处理 event/group.state_committed:验证 state_hash 链并更新本地存储。
|
|
1543
|
+
* 当 prev_state_hash 与本地不连续时回源 group.get_state,并对回源数据做 hash 验证。
|
|
1544
|
+
*/
|
|
1545
|
+
async _onGroupStateCommitted(data) {
|
|
1546
|
+
if (!isJsonObject(data))
|
|
1547
|
+
return;
|
|
1548
|
+
const d = data;
|
|
1549
|
+
const groupId = String(d.group_id ?? '').trim();
|
|
1550
|
+
if (!groupId)
|
|
1551
|
+
return;
|
|
1552
|
+
// 提交者签名验证
|
|
1553
|
+
const cs = d.client_signature;
|
|
1554
|
+
if (cs && isJsonObject(cs)) {
|
|
1555
|
+
const verified = await this._verifyEventSignature(d, cs);
|
|
1556
|
+
if (verified === false) {
|
|
1557
|
+
console.warn('[aun_core] state_committed 提交者签名验证失败 group=%s', groupId);
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
d._verified = verified;
|
|
1561
|
+
}
|
|
1562
|
+
const stateVersion = Number(d.state_version ?? 0);
|
|
1563
|
+
const stateHash = String(d.state_hash ?? '').trim();
|
|
1564
|
+
const prevStateHash = String(d.prev_state_hash ?? '').trim();
|
|
1565
|
+
const keyEpoch = Number(d.key_epoch ?? 0);
|
|
1566
|
+
const membershipSnapshot = String(d.membership_snapshot ?? '').trim();
|
|
1567
|
+
const policySnapshot = String(d.policy_snapshot ?? '').trim();
|
|
1568
|
+
// 1. 验证 prev_state_hash 连续性
|
|
1569
|
+
const loadFn = this._keystore.loadGroupState;
|
|
1570
|
+
const localState = loadFn
|
|
1571
|
+
? await loadFn.call(this._keystore, groupId)
|
|
1572
|
+
: null;
|
|
1573
|
+
if (localState && localState.state_hash && localState.state_hash !== prevStateHash) {
|
|
1574
|
+
console.warn('[aun_core] state_hash 链不连续 group=%s local_sv=%d event_sv=%d', groupId, localState.state_version, stateVersion);
|
|
1575
|
+
// 回源同步
|
|
1576
|
+
try {
|
|
1577
|
+
const serverState = await this._transport.call('group.get_state', { group_id: groupId });
|
|
1578
|
+
if (serverState && typeof serverState.state_version !== 'undefined') {
|
|
1579
|
+
const sv = Number(serverState.state_version);
|
|
1580
|
+
const sHash = String(serverState.state_hash ?? '');
|
|
1581
|
+
const sEpoch = Number(serverState.key_epoch ?? 0);
|
|
1582
|
+
const sMembersJson = String(serverState.membership_snapshot ?? '');
|
|
1583
|
+
const sPolicyJson = String(serverState.policy_snapshot ?? '');
|
|
1584
|
+
const sPrev = String(serverState.prev_state_hash ?? '');
|
|
1585
|
+
// 回源也做 hash 验证
|
|
1586
|
+
if (sMembersJson && sHash) {
|
|
1587
|
+
const sMembers = sMembersJson ? JSON.parse(sMembersJson) : [];
|
|
1588
|
+
const sPolicy = sPolicyJson ? JSON.parse(sPolicyJson) : {};
|
|
1589
|
+
const computed = await computeStateHash({
|
|
1590
|
+
groupId, stateVersion: sv, keyEpoch: sEpoch,
|
|
1591
|
+
members: sMembers, policy: sPolicy, prevStateHash: sPrev,
|
|
1592
|
+
});
|
|
1593
|
+
if (computed !== sHash) {
|
|
1594
|
+
console.warn('[aun_core] 回源 state_hash 验证失败 group=%s sv=%d expected=%s got=%s', groupId, sv, sHash, computed);
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
const saveFn = this._keystore.saveGroupState;
|
|
1599
|
+
if (saveFn) {
|
|
1600
|
+
await saveFn.call(this._keystore, groupId, {
|
|
1601
|
+
group_id: groupId,
|
|
1602
|
+
state_version: sv,
|
|
1603
|
+
state_hash: sHash,
|
|
1604
|
+
key_epoch: sEpoch,
|
|
1605
|
+
membership_json: sMembersJson || membershipSnapshot,
|
|
1606
|
+
policy_json: sPolicyJson || policySnapshot,
|
|
1607
|
+
updated_at: Date.now(),
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
catch (exc) {
|
|
1613
|
+
console.warn('[aun_core] state 回源失败 group=%s:', groupId, exc);
|
|
1614
|
+
}
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
// 2. 本地重算验证
|
|
1618
|
+
const members = membershipSnapshot ? JSON.parse(membershipSnapshot) : [];
|
|
1619
|
+
const policy = policySnapshot ? JSON.parse(policySnapshot) : {};
|
|
1620
|
+
const computed = await computeStateHash({
|
|
1621
|
+
groupId, stateVersion, keyEpoch,
|
|
1622
|
+
members, policy, prevStateHash,
|
|
1623
|
+
});
|
|
1624
|
+
if (computed !== stateHash) {
|
|
1625
|
+
console.warn('[aun_core] state_hash 重算不匹配 group=%s sv=%d expected=%s got=%s', groupId, stateVersion, stateHash, computed);
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
// 3. 更新本地存储
|
|
1629
|
+
const saveFn = this._keystore.saveGroupState;
|
|
1630
|
+
if (saveFn) {
|
|
1631
|
+
await saveFn.call(this._keystore, groupId, {
|
|
1632
|
+
group_id: groupId,
|
|
1633
|
+
state_version: stateVersion,
|
|
1634
|
+
state_hash: stateHash,
|
|
1635
|
+
key_epoch: keyEpoch,
|
|
1636
|
+
membership_json: membershipSnapshot,
|
|
1637
|
+
policy_json: policySnapshot,
|
|
1638
|
+
updated_at: Date.now(),
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1468
1642
|
/**
|
|
1469
1643
|
* 群组解散后清理本地状态:
|
|
1470
1644
|
* - keystore 中的 epoch key 数据
|
|
@@ -1542,6 +1716,17 @@ export class AUNClient {
|
|
|
1542
1716
|
}
|
|
1543
1717
|
}
|
|
1544
1718
|
// ── E2EE 自动加密 ────────────────────────────────
|
|
1719
|
+
_protectedHeadersFromParams(params) {
|
|
1720
|
+
const value = params.protected_headers ?? params.headers;
|
|
1721
|
+
if (value == null)
|
|
1722
|
+
return null;
|
|
1723
|
+
if (isJsonObject(value))
|
|
1724
|
+
return value;
|
|
1725
|
+
if (typeof value === 'object' && typeof value.toObject === 'function') {
|
|
1726
|
+
return value;
|
|
1727
|
+
}
|
|
1728
|
+
return null;
|
|
1729
|
+
}
|
|
1545
1730
|
/** 自动加密并发送 P2P 消息 */
|
|
1546
1731
|
async _sendEncrypted(params) {
|
|
1547
1732
|
const toAid = String(params.to ?? '');
|
|
@@ -1553,51 +1738,65 @@ export class AUNClient {
|
|
|
1553
1738
|
throw new ValidationError('message.send payload must be an object when encrypt=true');
|
|
1554
1739
|
}
|
|
1555
1740
|
const persistRequired = Boolean(params.persist_required || params.durable);
|
|
1741
|
+
const protectedHeaders = this._protectedHeadersFromParams(params);
|
|
1556
1742
|
// Lazy P2P sync:首次发送前自动拉取历史,避免重连后 seq 空洞
|
|
1557
1743
|
if (!this._p2pSynced) {
|
|
1558
1744
|
await this._lazySyncP2p();
|
|
1559
1745
|
}
|
|
1560
|
-
|
|
1561
|
-
const
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
if (recipientPrekeys.length <= 1 && selfSyncCopies.length === 0) {
|
|
1568
|
-
return await this._sendEncryptedSingle({
|
|
1569
|
-
toAid,
|
|
1570
|
-
payload,
|
|
1571
|
-
messageId,
|
|
1572
|
-
timestamp,
|
|
1573
|
-
prekey: recipientPrekeys[0],
|
|
1574
|
-
persistRequired,
|
|
1746
|
+
// 内部发送逻辑,refreshPeerMaterial=true 时强制清缓存重新拉取对端材料
|
|
1747
|
+
const sendAttempt = async (refreshPeerMaterial = false) => {
|
|
1748
|
+
const recipientPrekeys = refreshPeerMaterial
|
|
1749
|
+
? await this._refreshPeerPrekeys(toAid)
|
|
1750
|
+
: await this._fetchPeerPrekeys(toAid);
|
|
1751
|
+
const selfSyncCopies = await this._buildSelfSyncCopies({
|
|
1752
|
+
logicalToAid: toAid, payload, messageId, timestamp, protectedHeaders,
|
|
1575
1753
|
});
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1754
|
+
// 多设备过滤:只保留有有效 device_id 的可路由 prekey,
|
|
1755
|
+
// 占位符 PREKEY_FALLBACK_DEVICE_ID 表示服务端未分配真实设备 ID,不可用于多设备路由
|
|
1756
|
+
const routablePrekeys = recipientPrekeys.filter(pk => {
|
|
1757
|
+
const did = String(pk.device_id ?? '').trim();
|
|
1758
|
+
return did && did !== PREKEY_FALLBACK_DEVICE_ID;
|
|
1759
|
+
});
|
|
1760
|
+
const canUseMultiDevice = routablePrekeys.length > 0
|
|
1761
|
+
&& (routablePrekeys.length > 1 || selfSyncCopies.length > 0);
|
|
1762
|
+
if (!canUseMultiDevice) {
|
|
1763
|
+
return await this._sendEncryptedSingle({
|
|
1764
|
+
toAid, payload, messageId, timestamp,
|
|
1765
|
+
prekey: routablePrekeys[0] ?? recipientPrekeys[0],
|
|
1766
|
+
persistRequired, protectedHeaders,
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
const recipientCopies = await this._buildRecipientDeviceCopies({
|
|
1770
|
+
toAid, payload, messageId, timestamp,
|
|
1771
|
+
prekeys: routablePrekeys, protectedHeaders,
|
|
1772
|
+
});
|
|
1773
|
+
const sendParams = {
|
|
1774
|
+
to: toAid,
|
|
1775
|
+
payload: {
|
|
1776
|
+
type: 'e2ee.multi_device',
|
|
1777
|
+
logical_message_id: messageId,
|
|
1778
|
+
recipient_copies: recipientCopies,
|
|
1779
|
+
self_copies: selfSyncCopies,
|
|
1780
|
+
},
|
|
1587
1781
|
type: 'e2ee.multi_device',
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
}
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
timestamp,
|
|
1782
|
+
encrypted: true,
|
|
1783
|
+
message_id: messageId,
|
|
1784
|
+
timestamp,
|
|
1785
|
+
};
|
|
1786
|
+
if (persistRequired)
|
|
1787
|
+
sendParams.persist_required = true;
|
|
1788
|
+
return this._transport.call('message.send', sendParams);
|
|
1596
1789
|
};
|
|
1597
|
-
|
|
1598
|
-
|
|
1790
|
+
// 首次尝试(使用缓存);若对端证书/prekey 过期导致指纹不匹配,清缓存后重试一次
|
|
1791
|
+
try {
|
|
1792
|
+
return await sendAttempt(false);
|
|
1599
1793
|
}
|
|
1600
|
-
|
|
1794
|
+
catch (exc) {
|
|
1795
|
+
if (!isRetryablePeerMaterialError(exc))
|
|
1796
|
+
throw exc;
|
|
1797
|
+
console.warn(`[aun_core] peer cert/prekey mismatch for ${toAid}, refreshing and retrying once`);
|
|
1798
|
+
}
|
|
1799
|
+
return await sendAttempt(true);
|
|
1601
1800
|
}
|
|
1602
1801
|
/**
|
|
1603
1802
|
* 首次发送 P2P 消息前懒拉取历史消息,同步 seqTracker 避免空洞。
|
|
@@ -1640,6 +1839,7 @@ export class AUNClient {
|
|
|
1640
1839
|
prekey,
|
|
1641
1840
|
messageId: opts.messageId,
|
|
1642
1841
|
timestamp: opts.timestamp,
|
|
1842
|
+
protectedHeaders: opts.protectedHeaders,
|
|
1643
1843
|
});
|
|
1644
1844
|
await this._ensureEncryptResult(opts.toAid, encryptResult);
|
|
1645
1845
|
const sendParams = {
|
|
@@ -1658,8 +1858,10 @@ export class AUNClient {
|
|
|
1658
1858
|
async _buildRecipientDeviceCopies(opts) {
|
|
1659
1859
|
const recipientCopies = [];
|
|
1660
1860
|
const certCache = new Map();
|
|
1661
|
-
for (const prekey of opts.prekeys) {
|
|
1861
|
+
for (const prekey of normalizePeerPrekeys(opts.prekeys)) {
|
|
1662
1862
|
const deviceId = String(prekey.device_id ?? '').trim();
|
|
1863
|
+
if (!deviceId)
|
|
1864
|
+
continue; // 跳过无 device_id 的 prekey,防止构造不可路由的多设备副本
|
|
1663
1865
|
const peerCertFingerprint = String(prekey.cert_fingerprint ?? '').trim().toLowerCase();
|
|
1664
1866
|
const cacheKey = peerCertFingerprint || '__default__';
|
|
1665
1867
|
let peerCertPem = certCache.get(cacheKey);
|
|
@@ -1674,6 +1876,7 @@ export class AUNClient {
|
|
|
1674
1876
|
prekey,
|
|
1675
1877
|
messageId: opts.messageId,
|
|
1676
1878
|
timestamp: opts.timestamp,
|
|
1879
|
+
protectedHeaders: opts.protectedHeaders,
|
|
1677
1880
|
});
|
|
1678
1881
|
await this._ensureEncryptResult(opts.toAid, encryptResult);
|
|
1679
1882
|
recipientCopies.push({
|
|
@@ -1715,7 +1918,7 @@ export class AUNClient {
|
|
|
1715
1918
|
const myAid = this._aid;
|
|
1716
1919
|
if (!myAid)
|
|
1717
1920
|
return [];
|
|
1718
|
-
const prekeys = await this._fetchPeerPrekeys(myAid);
|
|
1921
|
+
const prekeys = normalizePeerPrekeys(await this._fetchPeerPrekeys(myAid));
|
|
1719
1922
|
if (prekeys.length === 0)
|
|
1720
1923
|
return [];
|
|
1721
1924
|
const copies = [];
|
|
@@ -1724,7 +1927,15 @@ export class AUNClient {
|
|
|
1724
1927
|
if (deviceId && deviceId === this._deviceId) {
|
|
1725
1928
|
continue;
|
|
1726
1929
|
}
|
|
1727
|
-
|
|
1930
|
+
let peerCertPem;
|
|
1931
|
+
try {
|
|
1932
|
+
peerCertPem = await this._resolveSelfCopyPeerCert(String(prekey.cert_fingerprint ?? '').trim().toLowerCase() || undefined);
|
|
1933
|
+
}
|
|
1934
|
+
catch (e) {
|
|
1935
|
+
// 旧设备的 prekey 可能携带已轮换的证书指纹,跳过该设备的自同步副本
|
|
1936
|
+
console.warn(`self-sync 跳过设备 ${deviceId}: 证书解析失败 (${e}),可能是旧 prekey`);
|
|
1937
|
+
continue;
|
|
1938
|
+
}
|
|
1728
1939
|
const [envelope, encryptResult] = await this._encryptCopyPayload({
|
|
1729
1940
|
logicalToAid: opts.logicalToAid,
|
|
1730
1941
|
payload: opts.payload,
|
|
@@ -1732,6 +1943,7 @@ export class AUNClient {
|
|
|
1732
1943
|
prekey,
|
|
1733
1944
|
messageId: opts.messageId,
|
|
1734
1945
|
timestamp: opts.timestamp,
|
|
1946
|
+
protectedHeaders: opts.protectedHeaders,
|
|
1735
1947
|
});
|
|
1736
1948
|
await this._ensureEncryptResult(myAid, encryptResult);
|
|
1737
1949
|
copies.push({
|
|
@@ -1747,6 +1959,8 @@ export class AUNClient {
|
|
|
1747
1959
|
prekey: opts.prekey ?? null,
|
|
1748
1960
|
messageId: opts.messageId,
|
|
1749
1961
|
timestamp: opts.timestamp,
|
|
1962
|
+
protectedHeaders: opts.protectedHeaders,
|
|
1963
|
+
context: opts.context ?? null,
|
|
1750
1964
|
});
|
|
1751
1965
|
return [envelope, encryptResult];
|
|
1752
1966
|
}
|
|
@@ -1862,7 +2076,7 @@ export class AUNClient {
|
|
|
1862
2076
|
return this._callGroupEncryptedRpc('group.thought.put', params, {
|
|
1863
2077
|
idField: 'thought_id',
|
|
1864
2078
|
idPrefix: 'gt',
|
|
1865
|
-
extraFields: ['
|
|
2079
|
+
extraFields: ['context'],
|
|
1866
2080
|
});
|
|
1867
2081
|
}
|
|
1868
2082
|
async _putMessageThoughtEncrypted(params) {
|
|
@@ -1887,6 +2101,8 @@ export class AUNClient {
|
|
|
1887
2101
|
prekey,
|
|
1888
2102
|
messageId: thoughtId,
|
|
1889
2103
|
timestamp,
|
|
2104
|
+
protectedHeaders: this._protectedHeadersFromParams(params),
|
|
2105
|
+
context: isJsonObject(params.context) ? params.context : null,
|
|
1890
2106
|
});
|
|
1891
2107
|
await this._ensureEncryptResult(toAid, encryptResult);
|
|
1892
2108
|
const sendParams = {
|
|
@@ -1896,8 +2112,9 @@ export class AUNClient {
|
|
|
1896
2112
|
encrypted: true,
|
|
1897
2113
|
thought_id: thoughtId,
|
|
1898
2114
|
timestamp,
|
|
1899
|
-
reply_to: params.reply_to,
|
|
1900
2115
|
};
|
|
2116
|
+
if ('context' in params)
|
|
2117
|
+
sendParams.context = params.context;
|
|
1901
2118
|
await this._signClientOperation('message.thought.put', sendParams);
|
|
1902
2119
|
return this._transport.call('message.thought.put', sendParams);
|
|
1903
2120
|
}
|
|
@@ -1934,12 +2151,26 @@ export class AUNClient {
|
|
|
1934
2151
|
await this._waitForGroupMembershipEpochFloor(groupId, 2000);
|
|
1935
2152
|
const epochResult = await this._committedGroupEpochState(groupId);
|
|
1936
2153
|
const committedEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
1937
|
-
const envelope = committedEpoch > 0
|
|
1938
|
-
? await this._groupE2ee.encryptWithEpoch(groupId, await this._ensureCommittedGroupSecretForSend(groupId, committedEpoch, epochResult), payload)
|
|
1939
|
-
: await this._groupE2ee.encrypt(groupId, payload);
|
|
1940
2154
|
const operationId = String(params[options.idField] ?? '').trim()
|
|
1941
2155
|
|| `${options.idPrefix}-${crypto.randomUUID()}`;
|
|
1942
2156
|
const timestamp = Number(params.timestamp ?? Date.now());
|
|
2157
|
+
const protectedHeaders = this._protectedHeadersFromParams(params);
|
|
2158
|
+
const context = method === 'group.thought.put' && isJsonObject(params.context)
|
|
2159
|
+
? params.context
|
|
2160
|
+
: null;
|
|
2161
|
+
const envelope = committedEpoch > 0
|
|
2162
|
+
? await this._groupE2ee.encryptWithEpoch(groupId, await this._ensureCommittedGroupSecretForSend(groupId, committedEpoch, epochResult), payload, {
|
|
2163
|
+
messageId: operationId,
|
|
2164
|
+
timestamp,
|
|
2165
|
+
protectedHeaders,
|
|
2166
|
+
context,
|
|
2167
|
+
})
|
|
2168
|
+
: await this._groupE2ee.encrypt(groupId, payload, {
|
|
2169
|
+
messageId: operationId,
|
|
2170
|
+
timestamp,
|
|
2171
|
+
protectedHeaders,
|
|
2172
|
+
context,
|
|
2173
|
+
});
|
|
1943
2174
|
const sendParams = {
|
|
1944
2175
|
group_id: groupId,
|
|
1945
2176
|
payload: envelope,
|
|
@@ -2072,7 +2303,7 @@ export class AUNClient {
|
|
|
2072
2303
|
console.warn(`[aun_core] group ${groupId} epoch precheck failed: ${formatCaughtError(exc)}`);
|
|
2073
2304
|
return;
|
|
2074
2305
|
}
|
|
2075
|
-
let serverEpoch = Number(epochResult.epoch ?? 0);
|
|
2306
|
+
let serverEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
2076
2307
|
if (!Number.isFinite(serverEpoch))
|
|
2077
2308
|
return;
|
|
2078
2309
|
const pending = isJsonObject(epochResult.pending_rotation) ? epochResult.pending_rotation : null;
|
|
@@ -2088,7 +2319,7 @@ export class AUNClient {
|
|
|
2088
2319
|
let effectiveLocalEpoch = initialLocalEpoch;
|
|
2089
2320
|
if (serverEpoch === 0 && effectiveLocalEpoch === 1) {
|
|
2090
2321
|
epochResult = await this._recoverInitialGroupEpochIfNeeded(groupId, effectiveLocalEpoch, epochResult);
|
|
2091
|
-
serverEpoch = Number(epochResult.epoch ?? 0);
|
|
2322
|
+
serverEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
2092
2323
|
if (serverEpoch === 0) {
|
|
2093
2324
|
throw new StateError(`group ${groupId} initial epoch sync has not completed; refuse to send with local epoch 1 while server epoch is 0`);
|
|
2094
2325
|
}
|
|
@@ -2100,7 +2331,9 @@ export class AUNClient {
|
|
|
2100
2331
|
while (Date.now() < waitDeadline) {
|
|
2101
2332
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
2102
2333
|
const refreshed = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
2103
|
-
const refreshedEpoch = isJsonObject(refreshed)
|
|
2334
|
+
const refreshedEpoch = isJsonObject(refreshed)
|
|
2335
|
+
? Number(refreshed.committed_epoch ?? refreshed.epoch ?? 0)
|
|
2336
|
+
: 0;
|
|
2104
2337
|
const currentLocal = await this._groupE2ee.currentEpoch(groupId);
|
|
2105
2338
|
if (Number.isFinite(refreshedEpoch) && refreshedEpoch > serverEpoch) {
|
|
2106
2339
|
epochResult = refreshed;
|
|
@@ -2116,7 +2349,7 @@ export class AUNClient {
|
|
|
2116
2349
|
}
|
|
2117
2350
|
}
|
|
2118
2351
|
console.warn(`[aun_core] group ${groupId} local epoch=${effectiveLocalEpoch} < server epoch=${serverEpoch}; requesting key recovery`);
|
|
2119
|
-
await this.
|
|
2352
|
+
await this._recoverGroupEpochKey(groupId, serverEpoch, '', 5000);
|
|
2120
2353
|
const deadline = Date.now() + 5000;
|
|
2121
2354
|
while (Date.now() < deadline) {
|
|
2122
2355
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
@@ -2189,8 +2422,23 @@ export class AUNClient {
|
|
|
2189
2422
|
async _ensureCommittedGroupSecretForSend(groupId, committedEpoch, epochResult) {
|
|
2190
2423
|
if (committedEpoch <= 0)
|
|
2191
2424
|
return committedEpoch;
|
|
2192
|
-
|
|
2193
|
-
|
|
2425
|
+
let secretData = await this._groupE2ee.loadSecret(groupId, committedEpoch);
|
|
2426
|
+
let committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
|
|
2427
|
+
if (await this._committedRotationMembershipGap(groupId, committedEpoch, committedRotation)) {
|
|
2428
|
+
const allowMember = await this._groupAllowsMemberEpochRotation(groupId);
|
|
2429
|
+
console.warn(`[aun_core] 群 ${groupId} committed epoch ${committedEpoch} 的成员快照与当前成员不一致,触发成员变更轮换修复`);
|
|
2430
|
+
await this._maybeLeadRotateGroupEpoch(groupId, `${groupId}:committed_membership_gap:aid:${this._aid}:epoch:${committedEpoch}`, committedEpoch, allowMember);
|
|
2431
|
+
const refreshed = await this._committedGroupEpochState(groupId);
|
|
2432
|
+
const refreshedCommittedEpoch = Number(refreshed.committed_epoch ?? refreshed.epoch ?? committedEpoch);
|
|
2433
|
+
if (Number.isFinite(refreshedCommittedEpoch) && refreshedCommittedEpoch > committedEpoch) {
|
|
2434
|
+
committedEpoch = refreshedCommittedEpoch;
|
|
2435
|
+
committedRotation = isJsonObject(refreshed.committed_rotation) ? refreshed.committed_rotation : null;
|
|
2436
|
+
secretData = await this._groupE2ee.loadSecret(groupId, committedEpoch);
|
|
2437
|
+
}
|
|
2438
|
+
if (await this._committedRotationMembershipGap(groupId, committedEpoch, committedRotation)) {
|
|
2439
|
+
throw new StateError(`group ${groupId} committed membership is stale at epoch ${committedEpoch}; key rotation repair has not completed`);
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2194
2442
|
if (this._groupSecretMatchesCommittedRotation(secretData, committedRotation)) {
|
|
2195
2443
|
return committedEpoch;
|
|
2196
2444
|
}
|
|
@@ -2211,6 +2459,45 @@ export class AUNClient {
|
|
|
2211
2459
|
}
|
|
2212
2460
|
return committedEpoch;
|
|
2213
2461
|
}
|
|
2462
|
+
async _committedRotationMembershipGap(groupId, committedEpoch, committedRotation) {
|
|
2463
|
+
if (!this._aid || committedEpoch <= 0 || !committedRotation)
|
|
2464
|
+
return false;
|
|
2465
|
+
const expectedMembers = Array.isArray(committedRotation.expected_members)
|
|
2466
|
+
? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean).sort()
|
|
2467
|
+
: [];
|
|
2468
|
+
if (expectedMembers.length === 0)
|
|
2469
|
+
return false;
|
|
2470
|
+
try {
|
|
2471
|
+
const membersResult = await this.call('group.get_members', { group_id: groupId });
|
|
2472
|
+
const rawMembers = isJsonObject(membersResult)
|
|
2473
|
+
? (Array.isArray(membersResult.members) ? membersResult.members : membersResult.items)
|
|
2474
|
+
: [];
|
|
2475
|
+
if (!Array.isArray(rawMembers))
|
|
2476
|
+
return false;
|
|
2477
|
+
const activeMembers = rawMembers
|
|
2478
|
+
.filter((item) => isJsonObject(item))
|
|
2479
|
+
.map((item) => ({
|
|
2480
|
+
aid: String(item.aid ?? '').trim(),
|
|
2481
|
+
status: String(item.status ?? 'active').trim().toLowerCase(),
|
|
2482
|
+
}))
|
|
2483
|
+
.filter((item) => item.aid && ['', 'active'].includes(item.status))
|
|
2484
|
+
.map((item) => item.aid)
|
|
2485
|
+
.sort();
|
|
2486
|
+
if (!activeMembers.includes(this._aid) || activeMembers.length === 0)
|
|
2487
|
+
return false;
|
|
2488
|
+
if (activeMembers.join('\n') !== expectedMembers.join('\n')) {
|
|
2489
|
+
const missing = activeMembers.filter((aid) => !expectedMembers.includes(aid));
|
|
2490
|
+
const extra = expectedMembers.filter((aid) => !activeMembers.includes(aid));
|
|
2491
|
+
console.info(`[aun_core] 群 ${groupId} committed membership gap: epoch=${committedEpoch} missing=${JSON.stringify(missing)} extra=${JSON.stringify(extra)}`);
|
|
2492
|
+
return true;
|
|
2493
|
+
}
|
|
2494
|
+
return false;
|
|
2495
|
+
}
|
|
2496
|
+
catch (exc) {
|
|
2497
|
+
console.debug(`[aun_core] 查询当前成员失败,无法判断 committed membership gap: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
2498
|
+
return false;
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2214
2501
|
// ── E2EE 自动解密 ────────────────────────────────
|
|
2215
2502
|
/** 解密单条 P2P 消息 */
|
|
2216
2503
|
async _decryptSingleMessage(message) {
|
|
@@ -2256,19 +2543,25 @@ export class AUNClient {
|
|
|
2256
2543
|
if (payload !== null
|
|
2257
2544
|
&& payload.type === 'e2ee.encrypted'
|
|
2258
2545
|
&& (msg.encrypted === true || !('encrypted' in msg))) {
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2546
|
+
try {
|
|
2547
|
+
const fromAid = (msg.from ?? '');
|
|
2548
|
+
const senderCertFingerprint = String(payload.sender_cert_fingerprint ?? payload.aad?.sender_cert_fingerprint ?? '').trim().toLowerCase();
|
|
2549
|
+
if (fromAid) {
|
|
2550
|
+
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
2551
|
+
if (!certReady) {
|
|
2552
|
+
console.warn('[aun_core] 无法获取发送方 %s 的证书,跳过解密', fromAid);
|
|
2553
|
+
continue;
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
// Pull 场景:跳过防重放和 timestamp 窗口检查(push 已处理过的消息仍需要能解密)
|
|
2557
|
+
const decrypted = await this._e2ee.decryptMessage(msg, { skipReplay: true });
|
|
2558
|
+
if (decrypted !== null) {
|
|
2559
|
+
result.push(decrypted);
|
|
2266
2560
|
}
|
|
2267
2561
|
}
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
result.push(decrypted);
|
|
2562
|
+
catch (decryptExc) {
|
|
2563
|
+
console.warn('[aun_core] pull 消息解密失败,跳过: from=%s mid=%s err=%s', (msg.from ?? ''), mid, decryptExc instanceof Error ? decryptExc.message : String(decryptExc));
|
|
2564
|
+
continue;
|
|
2272
2565
|
}
|
|
2273
2566
|
}
|
|
2274
2567
|
else {
|
|
@@ -2339,7 +2632,251 @@ export class AUNClient {
|
|
|
2339
2632
|
this._groupEpochRecoveryInflight.set(key, promise);
|
|
2340
2633
|
return promise;
|
|
2341
2634
|
}
|
|
2635
|
+
static _extractGroupJoinMode(payload) {
|
|
2636
|
+
if (!isJsonObject(payload))
|
|
2637
|
+
return '';
|
|
2638
|
+
for (const key of ['join_mode', 'mode']) {
|
|
2639
|
+
const v = String(payload[key] ?? '').trim().toLowerCase();
|
|
2640
|
+
if (v)
|
|
2641
|
+
return v;
|
|
2642
|
+
}
|
|
2643
|
+
for (const key of ['join_requirements', 'join']) {
|
|
2644
|
+
const nested = payload[key];
|
|
2645
|
+
if (isJsonObject(nested)) {
|
|
2646
|
+
for (const nk of ['mode', 'join_mode']) {
|
|
2647
|
+
const v = String(nested[nk] ?? '').trim().toLowerCase();
|
|
2648
|
+
if (v)
|
|
2649
|
+
return v;
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
if (isJsonObject(payload.group)) {
|
|
2654
|
+
const v = AUNClient._extractGroupJoinMode(payload.group);
|
|
2655
|
+
if (v)
|
|
2656
|
+
return v;
|
|
2657
|
+
}
|
|
2658
|
+
const settings = payload.settings;
|
|
2659
|
+
if (isJsonObject(settings)) {
|
|
2660
|
+
for (const key of ['join.mode', 'join_mode', 'mode']) {
|
|
2661
|
+
const v = String(settings[key] ?? '').trim().toLowerCase();
|
|
2662
|
+
if (v)
|
|
2663
|
+
return v;
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
if (Array.isArray(settings)) {
|
|
2667
|
+
for (const item of settings) {
|
|
2668
|
+
if (!isJsonObject(item))
|
|
2669
|
+
continue;
|
|
2670
|
+
const k = String(item.key ?? item.name ?? '').trim().toLowerCase();
|
|
2671
|
+
if (k === 'join.mode' || k === 'join_mode' || k === 'mode') {
|
|
2672
|
+
const v = String(item.value ?? '').trim().toLowerCase();
|
|
2673
|
+
if (v)
|
|
2674
|
+
return v;
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
return '';
|
|
2679
|
+
}
|
|
2680
|
+
static _joinModeAllowsMemberEpochRotation(mode) {
|
|
2681
|
+
const m = mode.trim().toLowerCase();
|
|
2682
|
+
return m === 'open' || m === 'invite_only' || m === 'invite_code';
|
|
2683
|
+
}
|
|
2684
|
+
async _groupAllowsMemberEpochRotation(groupId) {
|
|
2685
|
+
try {
|
|
2686
|
+
const resp = await this.call('group.get_join_requirements', { group_id: groupId });
|
|
2687
|
+
const mode = AUNClient._extractGroupJoinMode(resp);
|
|
2688
|
+
if (mode)
|
|
2689
|
+
return AUNClient._joinModeAllowsMemberEpochRotation(mode);
|
|
2690
|
+
}
|
|
2691
|
+
catch { /* best effort */ }
|
|
2692
|
+
try {
|
|
2693
|
+
const resp = await this.call('group.get_settings', { group_id: groupId, keys: ['join.mode'] });
|
|
2694
|
+
const mode = AUNClient._extractGroupJoinMode(resp);
|
|
2695
|
+
if (mode)
|
|
2696
|
+
return AUNClient._joinModeAllowsMemberEpochRotation(mode);
|
|
2697
|
+
}
|
|
2698
|
+
catch { /* best effort */ }
|
|
2699
|
+
try {
|
|
2700
|
+
const resp = await this.call('group.get', { group_id: groupId });
|
|
2701
|
+
const mode = AUNClient._extractGroupJoinMode(resp);
|
|
2702
|
+
if (mode)
|
|
2703
|
+
return AUNClient._joinModeAllowsMemberEpochRotation(mode);
|
|
2704
|
+
}
|
|
2705
|
+
catch { /* best effort */ }
|
|
2706
|
+
return false;
|
|
2707
|
+
}
|
|
2708
|
+
/** 尝试从服务端拉取 ECIES 加密的 epoch key 并解密存入 keystore */
|
|
2709
|
+
async _tryRecoverEpochKeyFromServer(groupId, epoch) {
|
|
2710
|
+
try {
|
|
2711
|
+
const params = { group_id: groupId };
|
|
2712
|
+
if (epoch > 0)
|
|
2713
|
+
params.epoch = epoch;
|
|
2714
|
+
const result = await this.call('group.e2ee.get_epoch_key', params);
|
|
2715
|
+
if (!isJsonObject(result))
|
|
2716
|
+
return false;
|
|
2717
|
+
const encryptedB64 = result.encrypted_key;
|
|
2718
|
+
if (!encryptedB64 || typeof encryptedB64 !== 'string')
|
|
2719
|
+
return false;
|
|
2720
|
+
const serverEpoch = Number(result.epoch ?? epoch);
|
|
2721
|
+
const encryptedBytes = base64ToUint8(encryptedB64);
|
|
2722
|
+
// 用自己的 AID 私钥 ECIES 解密
|
|
2723
|
+
const myAid = this._aid || '';
|
|
2724
|
+
const keyPair = await this._keystore.loadKeyPair(myAid);
|
|
2725
|
+
if (!keyPair?.private_key_pem)
|
|
2726
|
+
return false;
|
|
2727
|
+
const { eciesDecrypt } = await import('./e2ee-group.js');
|
|
2728
|
+
const groupSecret = await eciesDecrypt(keyPair.private_key_pem, encryptedBytes);
|
|
2729
|
+
if (!groupSecret || groupSecret.length !== 32)
|
|
2730
|
+
return false;
|
|
2731
|
+
// 获取成员列表和 committed_rotation 用于 commitment / epoch_chain 验证
|
|
2732
|
+
let memberAids = [];
|
|
2733
|
+
let committedRotation = null;
|
|
2734
|
+
let epochChain = '';
|
|
2735
|
+
try {
|
|
2736
|
+
const epochInfo = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
2737
|
+
if (isJsonObject(epochInfo)) {
|
|
2738
|
+
if (Array.isArray(epochInfo.members)) {
|
|
2739
|
+
memberAids = epochInfo.members
|
|
2740
|
+
.map((m) => {
|
|
2741
|
+
if (typeof m === 'string')
|
|
2742
|
+
return m;
|
|
2743
|
+
if (isJsonObject(m) && typeof m.aid === 'string')
|
|
2744
|
+
return m.aid;
|
|
2745
|
+
return '';
|
|
2746
|
+
})
|
|
2747
|
+
.filter((s) => s.length > 0);
|
|
2748
|
+
}
|
|
2749
|
+
if (isJsonObject(epochInfo.committed_rotation)) {
|
|
2750
|
+
committedRotation = epochInfo.committed_rotation;
|
|
2751
|
+
const rawChain = String(committedRotation.epoch_chain ?? '').trim();
|
|
2752
|
+
if (rawChain)
|
|
2753
|
+
epochChain = rawChain;
|
|
2754
|
+
if (Array.isArray(committedRotation.expected_members) && committedRotation.expected_members.length > 0) {
|
|
2755
|
+
memberAids = committedRotation.expected_members
|
|
2756
|
+
.map(item => String(item ?? '').trim())
|
|
2757
|
+
.filter(s => s.length > 0);
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
catch { /* best effort */ }
|
|
2763
|
+
if (memberAids.length === 0)
|
|
2764
|
+
return false;
|
|
2765
|
+
const commitment = await computeMembershipCommitment(memberAids, serverEpoch, groupId, groupSecret);
|
|
2766
|
+
let epochChainUnverified = null;
|
|
2767
|
+
let epochChainUnverifiedReason = null;
|
|
2768
|
+
if (committedRotation) {
|
|
2769
|
+
const committedEpoch = Number(committedRotation.target_epoch ?? serverEpoch);
|
|
2770
|
+
const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
|
|
2771
|
+
if (committedEpoch === serverEpoch && committedCommitment && committedCommitment !== commitment) {
|
|
2772
|
+
return false;
|
|
2773
|
+
}
|
|
2774
|
+
if (epochChain && committedEpoch === serverEpoch) {
|
|
2775
|
+
let rotatorAid = '';
|
|
2776
|
+
for (const key of ['rotated_by', 'lease_owner', 'committed_by']) {
|
|
2777
|
+
const v = String(committedRotation[key] ?? '').trim();
|
|
2778
|
+
if (v) {
|
|
2779
|
+
rotatorAid = v;
|
|
2780
|
+
break;
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
const prevData = await this._groupE2ee.loadSecret(groupId, serverEpoch - 1);
|
|
2784
|
+
const prevChain = String(prevData?.epoch_chain ?? '').trim();
|
|
2785
|
+
if (prevChain && rotatorAid) {
|
|
2786
|
+
if (!await verifyEpochChain(epochChain, prevChain, serverEpoch, commitment, rotatorAid)) {
|
|
2787
|
+
return false;
|
|
2788
|
+
}
|
|
2789
|
+
epochChainUnverified = false;
|
|
2790
|
+
}
|
|
2791
|
+
else {
|
|
2792
|
+
epochChainUnverified = true;
|
|
2793
|
+
epochChainUnverifiedReason = prevChain ? 'missing_rotator_aid' : 'missing_prev_chain';
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
await storeGroupSecretEpoch(this._keystore, myAid, groupId, serverEpoch, groupSecret, commitment, memberAids, epochChain || undefined, '', epochChainUnverified, epochChainUnverifiedReason);
|
|
2798
|
+
return true;
|
|
2799
|
+
}
|
|
2800
|
+
catch {
|
|
2801
|
+
return false;
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
/** 为每个成员用其 AID 证书公钥 ECIES 加密 group_secret */
|
|
2805
|
+
async _buildEpochEncryptedKeys(info, memberAids, targetEpoch, groupId) {
|
|
2806
|
+
try {
|
|
2807
|
+
const { eciesEncrypt } = await import('./e2ee-group.js');
|
|
2808
|
+
// 从 distribution payload 中提取 group_secret
|
|
2809
|
+
let groupSecretBytes = null;
|
|
2810
|
+
const distributions = Array.isArray(info.distributions) ? info.distributions : [];
|
|
2811
|
+
for (const dist of distributions) {
|
|
2812
|
+
if (isJsonObject(dist) && isJsonObject(dist.payload)) {
|
|
2813
|
+
const gsB64 = dist.payload.group_secret;
|
|
2814
|
+
if (typeof gsB64 === 'string' && gsB64.length > 0) {
|
|
2815
|
+
groupSecretBytes = base64ToUint8(gsB64);
|
|
2816
|
+
break;
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
if (!groupSecretBytes) {
|
|
2821
|
+
const loaded = await this._groupE2ee.loadSecret(groupId, targetEpoch);
|
|
2822
|
+
if (loaded?.secret) {
|
|
2823
|
+
groupSecretBytes = loaded.secret;
|
|
2824
|
+
}
|
|
2825
|
+
else {
|
|
2826
|
+
return {};
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
const encryptedKeys = {};
|
|
2830
|
+
for (const aid of memberAids) {
|
|
2831
|
+
try {
|
|
2832
|
+
const certPem = await this._fetchPeerCert(aid);
|
|
2833
|
+
// 从 PEM 证书提取 EC 公钥(未压缩 65 字节)
|
|
2834
|
+
const pubkeyBytes = await this._extractEcPubkeyFromCert(certPem);
|
|
2835
|
+
if (!pubkeyBytes)
|
|
2836
|
+
continue;
|
|
2837
|
+
const ciphertext = await eciesEncrypt(pubkeyBytes, groupSecretBytes);
|
|
2838
|
+
encryptedKeys[aid] = uint8ToBase64(ciphertext);
|
|
2839
|
+
}
|
|
2840
|
+
catch {
|
|
2841
|
+
continue;
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
return encryptedKeys;
|
|
2845
|
+
}
|
|
2846
|
+
catch {
|
|
2847
|
+
return {};
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
/** 从 PEM 证书中提取 EC 公钥的未压缩字节(65B) */
|
|
2851
|
+
async _extractEcPubkeyFromCert(certPem) {
|
|
2852
|
+
try {
|
|
2853
|
+
// 导入证书公钥为 ECDSA CryptoKey(exportable)
|
|
2854
|
+
const pubKey = await importCertPublicKeyEcdsa(certPem);
|
|
2855
|
+
// 导出 JWK 获取 x, y 坐标
|
|
2856
|
+
const jwk = await crypto.subtle.exportKey('jwk', pubKey);
|
|
2857
|
+
if (jwk.crv !== 'P-256' || !jwk.x || !jwk.y)
|
|
2858
|
+
return null;
|
|
2859
|
+
// base64url → 标准 base64 → Uint8Array
|
|
2860
|
+
const xBytes = base64ToUint8(jwk.x.replace(/-/g, '+').replace(/_/g, '/'));
|
|
2861
|
+
const yBytes = base64ToUint8(jwk.y.replace(/-/g, '+').replace(/_/g, '/'));
|
|
2862
|
+
const result = new Uint8Array(65);
|
|
2863
|
+
result[0] = 0x04;
|
|
2864
|
+
result.set(xBytes, 1);
|
|
2865
|
+
result.set(yBytes, 33);
|
|
2866
|
+
return result;
|
|
2867
|
+
}
|
|
2868
|
+
catch {
|
|
2869
|
+
return null;
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2342
2872
|
async _doRecoverGroupEpochKey(groupId, epoch, senderAid, timeoutMs) {
|
|
2873
|
+
// 仅 open / invite_code 群允许从服务端拉取 ECIES 加密的 epoch key
|
|
2874
|
+
if (await this._groupAllowsMemberEpochRotation(groupId)) {
|
|
2875
|
+
if (await this._tryRecoverEpochKeyFromServer(groupId, epoch)) {
|
|
2876
|
+
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
2877
|
+
return true;
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2343
2880
|
let epochResult = { epoch };
|
|
2344
2881
|
try {
|
|
2345
2882
|
const raw = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
@@ -2353,7 +2890,30 @@ export class AUNClient {
|
|
|
2353
2890
|
const current = Array.isArray(epochResult.recovery_candidates) ? epochResult.recovery_candidates : [];
|
|
2354
2891
|
epochResult.recovery_candidates = [senderAid, ...current];
|
|
2355
2892
|
}
|
|
2356
|
-
|
|
2893
|
+
// 在线优先恢复:先查在线成员列表,只向在线成员发送密钥请求
|
|
2894
|
+
let onlineAids = null;
|
|
2895
|
+
try {
|
|
2896
|
+
const onlineResp = await this.call('group.get_online_members', { group_id: groupId });
|
|
2897
|
+
if (isJsonObject(onlineResp)) {
|
|
2898
|
+
const rawMembers = Array.isArray(onlineResp.members) ? onlineResp.members
|
|
2899
|
+
: Array.isArray(onlineResp.items) ? onlineResp.items : [];
|
|
2900
|
+
onlineAids = rawMembers
|
|
2901
|
+
.filter((m) => isJsonObject(m) && m.online === true && String(m.aid ?? '') !== this._aid)
|
|
2902
|
+
.map(m => String(m.aid ?? ''));
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
catch {
|
|
2906
|
+
// get_online_members 不可用时回退全量候选
|
|
2907
|
+
}
|
|
2908
|
+
if (onlineAids !== null) {
|
|
2909
|
+
if (onlineAids.length === 0) {
|
|
2910
|
+
return false;
|
|
2911
|
+
}
|
|
2912
|
+
await this._requestGroupKeyFromOnline(groupId, epoch, onlineAids, epochResult);
|
|
2913
|
+
}
|
|
2914
|
+
else {
|
|
2915
|
+
await this._requestGroupKeyFromCandidates(groupId, epoch, epochResult);
|
|
2916
|
+
}
|
|
2357
2917
|
const deadline = Date.now() + timeoutMs;
|
|
2358
2918
|
while (Date.now() < deadline) {
|
|
2359
2919
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
@@ -2369,6 +2929,22 @@ export class AUNClient {
|
|
|
2369
2929
|
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
2370
2930
|
return ready;
|
|
2371
2931
|
}
|
|
2932
|
+
/** 只向在线成员发送密钥恢复请求 */
|
|
2933
|
+
async _requestGroupKeyFromOnline(groupId, epoch, onlineAids, epochResult) {
|
|
2934
|
+
const candidates = await this._groupKeyRecoveryCandidates(groupId, epochResult);
|
|
2935
|
+
const ordered = [];
|
|
2936
|
+
for (const aid of candidates) {
|
|
2937
|
+
if (onlineAids.includes(aid) && !ordered.includes(aid))
|
|
2938
|
+
ordered.push(aid);
|
|
2939
|
+
}
|
|
2940
|
+
for (const aid of onlineAids) {
|
|
2941
|
+
if (!ordered.includes(aid))
|
|
2942
|
+
ordered.push(aid);
|
|
2943
|
+
}
|
|
2944
|
+
for (const aid of ordered) {
|
|
2945
|
+
await this._requestGroupKeyFrom(groupId, aid, epoch);
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2372
2948
|
async _groupEpochSecretReadyForRecovery(groupId, epoch, secret) {
|
|
2373
2949
|
if (!isJsonObject(secret))
|
|
2374
2950
|
return false;
|
|
@@ -2496,20 +3072,30 @@ export class AUNClient {
|
|
|
2496
3072
|
message_id: thoughtId,
|
|
2497
3073
|
payload: payload ?? {},
|
|
2498
3074
|
created_at: Number(item.created_at ?? 0),
|
|
3075
|
+
...(isJsonObject(item.context) ? { context: item.context } : {}),
|
|
2499
3076
|
};
|
|
2500
3077
|
const decrypted = await this._decryptGroupMessage(message, { skipReplay: true });
|
|
3078
|
+
let decryptFailed = false;
|
|
2501
3079
|
if (payload?.type === 'e2ee.group_encrypted' && groupId && !decrypted.e2ee) {
|
|
2502
|
-
|
|
2503
|
-
|
|
3080
|
+
decryptFailed = true;
|
|
3081
|
+
// 安全网:触发 epoch key 恢复(内部有去重,重复调用安全)
|
|
3082
|
+
const epoch = Number(payload.epoch ?? 0);
|
|
3083
|
+
if (epoch > 0) {
|
|
3084
|
+
this._recoverGroupEpochKey(groupId, epoch, senderAid, 5000).catch(() => { });
|
|
3085
|
+
}
|
|
2504
3086
|
}
|
|
2505
|
-
|
|
3087
|
+
const thought = {
|
|
2506
3088
|
thought_id: thoughtId,
|
|
2507
3089
|
message_id: thoughtId,
|
|
2508
|
-
|
|
2509
|
-
payload: decrypted.payload,
|
|
3090
|
+
payload: decryptFailed ? (payload ?? {}) : decrypted.payload,
|
|
2510
3091
|
created_at: item.created_at,
|
|
2511
3092
|
e2ee: decrypted.e2ee,
|
|
2512
|
-
}
|
|
3093
|
+
};
|
|
3094
|
+
if (decryptFailed)
|
|
3095
|
+
thought.decrypt_failed = true;
|
|
3096
|
+
if ('context' in item)
|
|
3097
|
+
thought.context = item.context;
|
|
3098
|
+
thoughts.push(thought);
|
|
2513
3099
|
}
|
|
2514
3100
|
return { ...result, thoughts };
|
|
2515
3101
|
}
|
|
@@ -2537,31 +3123,42 @@ export class AUNClient {
|
|
|
2537
3123
|
encrypted: item.encrypted !== false,
|
|
2538
3124
|
timestamp: Number(item.created_at ?? 0),
|
|
2539
3125
|
};
|
|
3126
|
+
if (isJsonObject(item.context))
|
|
3127
|
+
message.context = item.context;
|
|
2540
3128
|
let decrypted = message;
|
|
3129
|
+
let decryptFailed = false;
|
|
2541
3130
|
if (payload?.type === 'e2ee.encrypted') {
|
|
2542
3131
|
const senderCertFingerprint = String(payload.sender_cert_fingerprint ?? payload.aad?.sender_cert_fingerprint ?? '').trim().toLowerCase();
|
|
2543
3132
|
if (fromAid) {
|
|
2544
3133
|
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
2545
3134
|
if (!certReady) {
|
|
2546
|
-
console.warn('[aun_core]
|
|
2547
|
-
|
|
3135
|
+
console.warn('[aun_core] p2p.thought.decrypt failed: 无法获取发送方证书 thought_id=' + thoughtId + ' from=' + fromAid);
|
|
3136
|
+
decryptFailed = true;
|
|
2548
3137
|
}
|
|
2549
3138
|
}
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
3139
|
+
if (!decryptFailed) {
|
|
3140
|
+
decrypted = await this._e2ee.decryptMessage(message, { skipReplay: true });
|
|
3141
|
+
if (decrypted === null || (isJsonObject(decrypted.payload) && decrypted.payload.type === 'e2ee.encrypted')) {
|
|
3142
|
+
console.warn('[aun_core] p2p.thought.decrypt failed thought_id=' + thoughtId);
|
|
3143
|
+
decryptFailed = true;
|
|
3144
|
+
decrypted = message;
|
|
3145
|
+
}
|
|
2553
3146
|
}
|
|
2554
3147
|
}
|
|
2555
|
-
|
|
3148
|
+
const thought = {
|
|
2556
3149
|
thought_id: thoughtId,
|
|
2557
3150
|
message_id: thoughtId,
|
|
2558
|
-
reply_to: item.reply_to,
|
|
2559
3151
|
from: fromAid,
|
|
2560
3152
|
to: toAid,
|
|
2561
3153
|
payload: decrypted.payload,
|
|
2562
3154
|
created_at: item.created_at,
|
|
2563
3155
|
e2ee: decrypted.e2ee,
|
|
2564
|
-
}
|
|
3156
|
+
};
|
|
3157
|
+
if (decryptFailed)
|
|
3158
|
+
thought.decrypt_failed = true;
|
|
3159
|
+
if ('context' in item)
|
|
3160
|
+
thought.context = item.context;
|
|
3161
|
+
thoughts.push(thought);
|
|
2565
3162
|
}
|
|
2566
3163
|
return { ...result, thoughts };
|
|
2567
3164
|
}
|
|
@@ -2622,6 +3219,11 @@ export class AUNClient {
|
|
|
2622
3219
|
result = await this._groupE2ee.handleIncoming(actualPayload);
|
|
2623
3220
|
if (result === 'distribution') {
|
|
2624
3221
|
await this._discardGroupDistributionIfStale(actualPayload);
|
|
3222
|
+
// 收到 epoch key 说明该群有活动,触发惰性同步建立 seq 基线
|
|
3223
|
+
const distGroupId = actualPayload.group_id;
|
|
3224
|
+
if (distGroupId && !this._groupSynced.has(distGroupId)) {
|
|
3225
|
+
this._lazySyncGroup(distGroupId).catch(() => { });
|
|
3226
|
+
}
|
|
2625
3227
|
}
|
|
2626
3228
|
}
|
|
2627
3229
|
catch (exc) {
|
|
@@ -2641,7 +3243,9 @@ export class AUNClient {
|
|
|
2641
3243
|
const groupId = (actualPayload.group_id ?? '');
|
|
2642
3244
|
const requester = (actualPayload.requester_aid ?? '');
|
|
2643
3245
|
let members = await this._groupE2ee.getMemberAids(groupId);
|
|
2644
|
-
//
|
|
3246
|
+
// 请求者不在本地成员列表时,回源查询服务端最新成员列表(仅用于判断是否响应,不写回本地存储)
|
|
3247
|
+
// P0 历史隔离:不再将回源结果更新到当前 epoch 的 member_aids/commitment,
|
|
3248
|
+
// 避免用当前成员覆盖已固化的历史 epoch 快照。
|
|
2645
3249
|
if (requester && !members.includes(requester)) {
|
|
2646
3250
|
try {
|
|
2647
3251
|
const membersResult = await this.call('group.get_members', { group_id: groupId });
|
|
@@ -2649,15 +3253,6 @@ export class AUNClient {
|
|
|
2649
3253
|
? membersResult.members
|
|
2650
3254
|
: [];
|
|
2651
3255
|
members = memberList.map(m => String(m.aid ?? ''));
|
|
2652
|
-
// 更新本地当前 epoch 的 member_aids/commitment
|
|
2653
|
-
if (members.includes(requester)) {
|
|
2654
|
-
const secretData = await this._groupE2ee.loadSecret(groupId);
|
|
2655
|
-
if (secretData && this._aid) {
|
|
2656
|
-
const epoch = secretData.epoch;
|
|
2657
|
-
const commitment = await computeMembershipCommitment(members, epoch, groupId, secretData.secret);
|
|
2658
|
-
await storeGroupSecret(this._keystore, this._aid, groupId, epoch, secretData.secret, commitment, members);
|
|
2659
|
-
}
|
|
2660
|
-
}
|
|
2661
3256
|
}
|
|
2662
3257
|
catch (exc) {
|
|
2663
3258
|
console.warn(`群组 ${groupId} 成员列表回源失败:`, exc);
|
|
@@ -2781,11 +3376,15 @@ export class AUNClient {
|
|
|
2781
3376
|
async _fetchPeerPrekeys(peerAid) {
|
|
2782
3377
|
const cachedList = this._peerPrekeysCache.get(peerAid);
|
|
2783
3378
|
if (cachedList && Date.now() / 1000 < cachedList.expireAt) {
|
|
2784
|
-
|
|
3379
|
+
const normalized = normalizePeerPrekeys(cachedList.items);
|
|
3380
|
+
if (normalized.length > 0)
|
|
3381
|
+
return normalized.map((item) => ({ ...item }));
|
|
2785
3382
|
}
|
|
2786
3383
|
const cached = this._e2ee.getCachedPrekey(peerAid);
|
|
2787
|
-
if (cached !== null
|
|
2788
|
-
|
|
3384
|
+
if (cached !== null) {
|
|
3385
|
+
const normalized = normalizePeerPrekeys([cached]);
|
|
3386
|
+
if (normalized.length > 0)
|
|
3387
|
+
return normalized.map((item) => ({ ...item }));
|
|
2789
3388
|
}
|
|
2790
3389
|
let result;
|
|
2791
3390
|
try {
|
|
@@ -2802,11 +3401,11 @@ export class AUNClient {
|
|
|
2802
3401
|
}
|
|
2803
3402
|
const devicePrekeys = Array.isArray(result.device_prekeys) ? result.device_prekeys : null;
|
|
2804
3403
|
if (devicePrekeys) {
|
|
2805
|
-
const normalized = devicePrekeys
|
|
3404
|
+
const normalized = normalizePeerPrekeys(devicePrekeys);
|
|
2806
3405
|
if (normalized.length > 0) {
|
|
2807
3406
|
this._peerPrekeysCache.set(peerAid, {
|
|
2808
3407
|
items: normalized.map((item) => ({ ...item })),
|
|
2809
|
-
expireAt: Date.now() / 1000 +
|
|
3408
|
+
expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
|
|
2810
3409
|
});
|
|
2811
3410
|
this._e2ee.cachePrekey(peerAid, normalized[0]);
|
|
2812
3411
|
return normalized;
|
|
@@ -2816,23 +3415,47 @@ export class AUNClient {
|
|
|
2816
3415
|
throw new ValidationError(`invalid prekey response for ${peerAid}`);
|
|
2817
3416
|
}
|
|
2818
3417
|
if (result.prekey) {
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
3418
|
+
const normalized = normalizePeerPrekeys([result.prekey]);
|
|
3419
|
+
if (normalized.length > 0) {
|
|
3420
|
+
this._peerPrekeysCache.set(peerAid, {
|
|
3421
|
+
items: normalized.map((item) => ({ ...item })),
|
|
3422
|
+
expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
|
|
3423
|
+
});
|
|
3424
|
+
this._e2ee.cachePrekey(peerAid, normalized[0]);
|
|
3425
|
+
return normalized.map((item) => ({ ...item }));
|
|
3426
|
+
}
|
|
2825
3427
|
}
|
|
2826
3428
|
if (result.found) {
|
|
2827
3429
|
throw new ValidationError(`invalid prekey response for ${peerAid}`);
|
|
2828
3430
|
}
|
|
2829
3431
|
return [];
|
|
2830
3432
|
}
|
|
3433
|
+
/** 清除对端 prekey 的双层缓存(_peerPrekeysCache + e2ee 内部缓存) */
|
|
3434
|
+
_invalidatePeerPrekeyCache(peerAid) {
|
|
3435
|
+
this._peerPrekeysCache.delete(peerAid);
|
|
3436
|
+
this._e2ee.invalidatePrekeyCache(peerAid);
|
|
3437
|
+
}
|
|
3438
|
+
/** 清除对端证书缓存(精确匹配 aid 或 aid# 前缀的所有条目) */
|
|
3439
|
+
_clearPeerCertCache(peerAid) {
|
|
3440
|
+
for (const cacheKey of this._certCache.keys()) {
|
|
3441
|
+
if (cacheKey === peerAid || cacheKey.startsWith(`${peerAid}#`)) {
|
|
3442
|
+
this._certCache.delete(cacheKey);
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
/** 清除对端所有缓存后重新拉取 prekey(用于指纹不匹配时的强制刷新) */
|
|
3447
|
+
async _refreshPeerPrekeys(peerAid) {
|
|
3448
|
+
this._invalidatePeerPrekeyCache(peerAid);
|
|
3449
|
+
this._clearPeerCertCache(peerAid);
|
|
3450
|
+
return await this._fetchPeerPrekeys(peerAid);
|
|
3451
|
+
}
|
|
2831
3452
|
/** 获取对方 prekey(兼容接口,优先返回第一条 device prekey)。 */
|
|
2832
3453
|
async _fetchPeerPrekey(peerAid) {
|
|
2833
3454
|
const cachedList = this._peerPrekeysCache.get(peerAid);
|
|
2834
3455
|
if (cachedList && Date.now() / 1000 < cachedList.expireAt && cachedList.items.length > 0) {
|
|
2835
|
-
|
|
3456
|
+
const normalized = normalizePeerPrekeys(cachedList.items);
|
|
3457
|
+
if (normalized.length > 0)
|
|
3458
|
+
return { ...normalized[0] };
|
|
2836
3459
|
}
|
|
2837
3460
|
const prekeys = await this._fetchPeerPrekeys(peerAid);
|
|
2838
3461
|
if (prekeys.length === 0) {
|
|
@@ -2896,7 +3519,11 @@ export class AUNClient {
|
|
|
2896
3519
|
* 零信任要求:不直接信任 keystore 中可能由恶意服务端注入的证书。
|
|
2897
3520
|
*/
|
|
2898
3521
|
_getVerifiedPeerCert(aid, certFingerprint) {
|
|
2899
|
-
|
|
3522
|
+
let cached = this._certCache.get(certCacheKey(aid, certFingerprint));
|
|
3523
|
+
// 带 fingerprint 查不到时,降级用 aid 再查一次
|
|
3524
|
+
if (!cached && certFingerprint) {
|
|
3525
|
+
cached = this._certCache.get(certCacheKey(aid, undefined));
|
|
3526
|
+
}
|
|
2900
3527
|
const now = Date.now() / 1000;
|
|
2901
3528
|
if (cached && now < cached.validatedAt + PEER_CERT_CACHE_TTL * 2) {
|
|
2902
3529
|
return cached.certPem;
|
|
@@ -3068,8 +3695,17 @@ export class AUNClient {
|
|
|
3068
3695
|
if (Number.isFinite(epoch) && epoch > 0 && epoch <= committedEpoch) {
|
|
3069
3696
|
if (committedRotation && Number(committedRotation.target_epoch ?? committedEpoch) === epoch) {
|
|
3070
3697
|
const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
|
|
3071
|
-
if (committedCommitment && commitment && committedCommitment !== commitment)
|
|
3072
|
-
|
|
3698
|
+
if (committedCommitment && commitment && committedCommitment !== commitment) {
|
|
3699
|
+
const expectedMembers = Array.isArray(committedRotation.expected_members)
|
|
3700
|
+
? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean)
|
|
3701
|
+
: [];
|
|
3702
|
+
if (this._aid && !expectedMembers.includes(this._aid)) {
|
|
3703
|
+
console.debug(`[aun_core] 放行 group key 分发:新成员恢复 commitment 不匹配属正常 group=${groupId} epoch=${epoch}`);
|
|
3704
|
+
}
|
|
3705
|
+
else {
|
|
3706
|
+
return false;
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3073
3709
|
}
|
|
3074
3710
|
return true;
|
|
3075
3711
|
}
|
|
@@ -3138,8 +3774,17 @@ export class AUNClient {
|
|
|
3138
3774
|
const committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
|
|
3139
3775
|
if (committedRotation && Number(committedRotation.target_epoch ?? committedEpoch) === epoch) {
|
|
3140
3776
|
const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
|
|
3141
|
-
if (committedCommitment && commitment && committedCommitment !== commitment)
|
|
3142
|
-
|
|
3777
|
+
if (committedCommitment && commitment && committedCommitment !== commitment) {
|
|
3778
|
+
const expectedMembers = Array.isArray(committedRotation.expected_members)
|
|
3779
|
+
? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean)
|
|
3780
|
+
: [];
|
|
3781
|
+
if (this._aid && !expectedMembers.includes(this._aid)) {
|
|
3782
|
+
console.debug(`[aun_core] 放行 group key response:新成员恢复 commitment 不匹配属正常 group=${groupId} epoch=${epoch}`);
|
|
3783
|
+
}
|
|
3784
|
+
else {
|
|
3785
|
+
return false;
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3143
3788
|
}
|
|
3144
3789
|
return true;
|
|
3145
3790
|
}
|
|
@@ -3246,7 +3891,15 @@ export class AUNClient {
|
|
|
3246
3891
|
await this._abortGroupRotation(activeRotationId, 'self_ack_failed');
|
|
3247
3892
|
return;
|
|
3248
3893
|
}
|
|
3249
|
-
const
|
|
3894
|
+
const commitParams2 = { rotation_id: activeRotationId };
|
|
3895
|
+
const createMembers = secretData.member_aids.length > 0 ? secretData.member_aids : (this._aid ? [this._aid] : []);
|
|
3896
|
+
const encKeys2 = await this._buildEpochEncryptedKeys({ distributions: [{ payload: { group_secret: uint8ToBase64(secretData.secret) } }] }, createMembers, 1, groupId);
|
|
3897
|
+
if (await this._groupAllowsMemberEpochRotation(groupId)) {
|
|
3898
|
+
if (encKeys2 && Object.keys(encKeys2).length > 0) {
|
|
3899
|
+
commitParams2.encrypted_keys = encKeys2;
|
|
3900
|
+
}
|
|
3901
|
+
}
|
|
3902
|
+
const commitResult = await this.call('group.e2ee.commit_rotation', commitParams2);
|
|
3250
3903
|
if (isJsonObject(commitResult) && commitResult.success === true) {
|
|
3251
3904
|
await storeGroupSecret(this._keystore, this._aid, groupId, 1, secretData.secret, secretData.commitment, secretData.member_aids, secretData.epoch_chain);
|
|
3252
3905
|
return;
|
|
@@ -3274,7 +3927,7 @@ export class AUNClient {
|
|
|
3274
3927
|
* H21: 基于"排序最小 admin = leader"选举,其他 admin 走 jitter 兜底重试。
|
|
3275
3928
|
* 避免所有剩余 admin 同时触发 `_rotateGroupEpoch` 造成 CAS 风暴。
|
|
3276
3929
|
*/
|
|
3277
|
-
async _maybeLeadRotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null) {
|
|
3930
|
+
async _maybeLeadRotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null, allowMember = false) {
|
|
3278
3931
|
const myAid = this._aid;
|
|
3279
3932
|
if (!myAid || this._closing || this._state !== 'connected')
|
|
3280
3933
|
return;
|
|
@@ -3303,24 +3956,45 @@ export class AUNClient {
|
|
|
3303
3956
|
if (!Array.isArray(rawList))
|
|
3304
3957
|
return;
|
|
3305
3958
|
const admins = [];
|
|
3959
|
+
const members = [];
|
|
3306
3960
|
for (const m of rawList) {
|
|
3307
3961
|
if (!isJsonObject(m))
|
|
3308
3962
|
continue;
|
|
3309
3963
|
const role = String(m.role ?? '');
|
|
3310
3964
|
const aid = String(m.aid ?? '');
|
|
3311
|
-
if (aid
|
|
3965
|
+
if (!aid)
|
|
3966
|
+
continue;
|
|
3967
|
+
if (role === 'admin' || role === 'owner') {
|
|
3312
3968
|
admins.push(aid);
|
|
3969
|
+
}
|
|
3970
|
+
else if (allowMember && role === 'member') {
|
|
3971
|
+
members.push(aid);
|
|
3972
|
+
}
|
|
3313
3973
|
}
|
|
3314
|
-
|
|
3974
|
+
// 候选列表:admin/owner 排序在前,member 排序在后
|
|
3975
|
+
let candidates = [...admins.sort(), ...members.sort()];
|
|
3976
|
+
if (candidates.length === 0)
|
|
3315
3977
|
return;
|
|
3316
|
-
|
|
3317
|
-
|
|
3978
|
+
// 没有当前 epoch key 的成员不参与 leader 选举
|
|
3979
|
+
if (expectedEpoch !== null && expectedEpoch > 0) {
|
|
3980
|
+
const localSecret = await this._groupE2ee.loadSecret(groupId, expectedEpoch);
|
|
3981
|
+
if (!localSecret) {
|
|
3982
|
+
const filtered = candidates.filter(c => c !== myAid);
|
|
3983
|
+
if (filtered.length > 0) {
|
|
3984
|
+
candidates = filtered;
|
|
3985
|
+
}
|
|
3986
|
+
else if (!allowMember) {
|
|
3987
|
+
return;
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3990
|
+
}
|
|
3991
|
+
const leader = candidates[0];
|
|
3318
3992
|
if (leader === myAid) {
|
|
3319
3993
|
// 我是 leader,直接发起
|
|
3320
3994
|
await this._rotateGroupEpoch(groupId, triggerId, expectedEpoch);
|
|
3321
3995
|
return;
|
|
3322
3996
|
}
|
|
3323
|
-
if (!
|
|
3997
|
+
if (!candidates.includes(myAid))
|
|
3324
3998
|
return;
|
|
3325
3999
|
// 非 leader:随机 jitter(2~6s)后查询服务端 epoch 是否已被 leader 推进
|
|
3326
4000
|
const jitterMs = 2000 + Math.floor(Math.random() * 4000);
|
|
@@ -3410,8 +4084,21 @@ export class AUNClient {
|
|
|
3410
4084
|
}
|
|
3411
4085
|
const currentEpoch = expectedEpoch ?? serverEpoch;
|
|
3412
4086
|
const targetEpoch = currentEpoch + 1;
|
|
4087
|
+
let prevChainHint = null;
|
|
4088
|
+
const localPrev = await this._groupE2ee.loadSecret(groupId, currentEpoch);
|
|
4089
|
+
const localPrevChain = String(localPrev?.epoch_chain ?? '').trim();
|
|
4090
|
+
if (!localPrevChain && isJsonObject(epochResult)) {
|
|
4091
|
+
const cr = epochResult.committed_rotation;
|
|
4092
|
+
if (isJsonObject(cr)) {
|
|
4093
|
+
const rawChain = String(cr.epoch_chain ?? '').trim();
|
|
4094
|
+
if (rawChain) {
|
|
4095
|
+
prevChainHint = rawChain;
|
|
4096
|
+
console.info(`[aun_core] 轮换补充 prev epoch chain from server: group=${groupId} epoch=${currentEpoch}`);
|
|
4097
|
+
}
|
|
4098
|
+
}
|
|
4099
|
+
}
|
|
3413
4100
|
const rotationId = `rot-${_uuidV4().replace(/-/g, '')}`;
|
|
3414
|
-
const info = await this._groupE2ee.rotateEpochTo(groupId, targetEpoch, memberAids, { rotationId });
|
|
4101
|
+
const info = await this._groupE2ee.rotateEpochTo(groupId, targetEpoch, memberAids, { rotationId, prevChainHint });
|
|
3415
4102
|
this._attachRotationId(info, rotationId);
|
|
3416
4103
|
const discardGeneratedPending = async () => {
|
|
3417
4104
|
try {
|
|
@@ -3504,7 +4191,15 @@ export class AUNClient {
|
|
|
3504
4191
|
await discardGeneratedPending();
|
|
3505
4192
|
return;
|
|
3506
4193
|
}
|
|
3507
|
-
const
|
|
4194
|
+
const commitParams = { rotation_id: activeRotationId };
|
|
4195
|
+
// 构建 per-member ECIES 加密的 epoch key 上传到服务端
|
|
4196
|
+
if (await this._groupAllowsMemberEpochRotation(groupId)) {
|
|
4197
|
+
const encryptedKeys = await this._buildEpochEncryptedKeys(info, memberAids, targetEpoch, groupId);
|
|
4198
|
+
if (encryptedKeys && Object.keys(encryptedKeys).length > 0) {
|
|
4199
|
+
commitParams.encrypted_keys = encryptedKeys;
|
|
4200
|
+
}
|
|
4201
|
+
}
|
|
4202
|
+
const commitResult = await this.call('group.e2ee.commit_rotation', commitParams);
|
|
3508
4203
|
if (!isJsonObject(commitResult) || commitResult.success !== true) {
|
|
3509
4204
|
console.warn('group epoch commit failed (group=%s, rotation=%s, returned=%s)', groupId, activeRotationId, JSON.stringify(commitResult));
|
|
3510
4205
|
this._scheduleGroupRotationRetry(groupId, {
|
|
@@ -3544,6 +4239,78 @@ export class AUNClient {
|
|
|
3544
4239
|
this._logE2eeError('rotate_epoch', groupId, '', exc);
|
|
3545
4240
|
}
|
|
3546
4241
|
}
|
|
4242
|
+
/** 从成员加入事件 payload 中提取新加入的成员 AID 列表。 */
|
|
4243
|
+
_joinedMemberAidsFromPayload(payload) {
|
|
4244
|
+
const aids = new Set();
|
|
4245
|
+
const addAid = (value) => {
|
|
4246
|
+
const aid = String(value ?? '').trim();
|
|
4247
|
+
if (aid)
|
|
4248
|
+
aids.add(aid);
|
|
4249
|
+
};
|
|
4250
|
+
addAid(payload.aid ?? payload.applicant_aid ?? payload.applicantAid);
|
|
4251
|
+
for (const key of ['member_aid', 'target_aid', 'new_member_aid', 'used_by']) {
|
|
4252
|
+
addAid(payload[key]);
|
|
4253
|
+
}
|
|
4254
|
+
for (const key of ['member', 'request', 'invite_code']) {
|
|
4255
|
+
const nested = isJsonObject(payload[key]) ? payload[key] : null;
|
|
4256
|
+
if (!nested)
|
|
4257
|
+
continue;
|
|
4258
|
+
addAid(nested.aid ?? nested.applicant_aid ?? nested.applicantAid);
|
|
4259
|
+
for (const nk of ['member_aid', 'target_aid', 'used_by'])
|
|
4260
|
+
addAid(nested[nk]);
|
|
4261
|
+
}
|
|
4262
|
+
if (Array.isArray(payload.results)) {
|
|
4263
|
+
for (const item of payload.results) {
|
|
4264
|
+
if (!isJsonObject(item))
|
|
4265
|
+
continue;
|
|
4266
|
+
const obj = item;
|
|
4267
|
+
const status = String(obj.status ?? '').trim().toLowerCase();
|
|
4268
|
+
if (status !== 'approved' && obj.approved !== true)
|
|
4269
|
+
continue;
|
|
4270
|
+
addAid(obj.aid ?? obj.applicant_aid ?? obj.applicantAid);
|
|
4271
|
+
for (const key of ['member_aid', 'target_aid'])
|
|
4272
|
+
addAid(obj[key]);
|
|
4273
|
+
for (const key of ['member', 'request']) {
|
|
4274
|
+
const nested = isJsonObject(obj[key]) ? obj[key] : null;
|
|
4275
|
+
if (!nested)
|
|
4276
|
+
continue;
|
|
4277
|
+
addAid(nested.aid ?? nested.applicant_aid);
|
|
4278
|
+
for (const nk of ['member_aid', 'target_aid'])
|
|
4279
|
+
addAid(nested[nk]);
|
|
4280
|
+
}
|
|
4281
|
+
}
|
|
4282
|
+
}
|
|
4283
|
+
return Array.from(aids);
|
|
4284
|
+
}
|
|
4285
|
+
// ── 入群密钥恢复策略 ──────────────────────────────────────
|
|
4286
|
+
/** 延迟轮换等待时间(毫秒) */
|
|
4287
|
+
static _JOIN_ROTATION_DELAY_MS = 3000;
|
|
4288
|
+
// 新成员自身延迟轮换时间:优先让其他在线成员先轮换
|
|
4289
|
+
static _SELF_JOIN_ROTATION_DELAY_MS = 6000;
|
|
4290
|
+
/** open/invite_code 入群后延迟轮换。 */
|
|
4291
|
+
async _delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, allowMember = false, delayMs) {
|
|
4292
|
+
await new Promise(resolve => setTimeout(resolve, delayMs ?? AUNClient._JOIN_ROTATION_DELAY_MS));
|
|
4293
|
+
await this._maybeLeadRotateGroupEpoch(groupId, triggerId, expectedEpoch, allowMember);
|
|
4294
|
+
}
|
|
4295
|
+
/** 当新成员加入但缺少 old_epoch 时,将当前 epoch 密钥分发给新成员。 */
|
|
4296
|
+
async _maybeBackfillKeyToJoinedMember(groupId, payload, triggerId = '') {
|
|
4297
|
+
const memberAids = this._joinedMemberAidsFromPayload(payload)
|
|
4298
|
+
.filter(aid => aid && aid !== this._aid);
|
|
4299
|
+
if (!groupId || !this._aid || memberAids.length === 0)
|
|
4300
|
+
return;
|
|
4301
|
+
if (!(await this._groupE2ee.hasSecret(groupId)))
|
|
4302
|
+
return;
|
|
4303
|
+
for (const memberAid of memberAids) {
|
|
4304
|
+
const dedupeKey = `${triggerId || this._membershipRotationTriggerId(groupId, payload)}:backfill:${memberAid}`;
|
|
4305
|
+
if (this._groupMemberKeyBackfillDone.has(dedupeKey))
|
|
4306
|
+
continue;
|
|
4307
|
+
this._groupMemberKeyBackfillDone.add(dedupeKey);
|
|
4308
|
+
if (this._groupMemberKeyBackfillDone.size > 2000) {
|
|
4309
|
+
this._groupMemberKeyBackfillDone = new Set(Array.from(this._groupMemberKeyBackfillDone).slice(-1000));
|
|
4310
|
+
}
|
|
4311
|
+
await this._distributeKeyToNewMember(groupId, memberAid);
|
|
4312
|
+
}
|
|
4313
|
+
}
|
|
3547
4314
|
/**
|
|
3548
4315
|
* 将当前 group_secret 通过 P2P E2EE 分发给新成员。
|
|
3549
4316
|
* 先拉服务端最新成员列表,更新本地,构建签名 manifest,再分发。
|
|
@@ -3573,7 +4340,7 @@ export class AUNClient {
|
|
|
3573
4340
|
if (identity && identity.private_key_pem) {
|
|
3574
4341
|
manifest = await signMembershipManifest(manifest, identity.private_key_pem);
|
|
3575
4342
|
}
|
|
3576
|
-
const distPayload = await buildKeyDistribution(groupId, epoch, secretData.secret, memberAids, this._aid, manifest);
|
|
4343
|
+
const distPayload = await buildKeyDistribution(groupId, epoch, secretData.secret, memberAids, this._aid, manifest, String(secretData.epoch_chain ?? ''));
|
|
3577
4344
|
// 重试 3 次,间隔递增(1s, 2s)
|
|
3578
4345
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
3579
4346
|
try {
|
|
@@ -3829,8 +4596,6 @@ export class AUNClient {
|
|
|
3829
4596
|
this._startTokenRefresh();
|
|
3830
4597
|
this._startPrekeyRefresh();
|
|
3831
4598
|
this._startGroupEpochTasks();
|
|
3832
|
-
// 上线/重连后一次性补齐群消息和群事件
|
|
3833
|
-
this._safeAsync(this._syncAllGroupsOnce());
|
|
3834
4599
|
}
|
|
3835
4600
|
_stopBackgroundTasks() {
|
|
3836
4601
|
if (this._heartbeatTimer !== null) {
|
|
@@ -3866,9 +4631,10 @@ export class AUNClient {
|
|
|
3866
4631
|
_startHeartbeat() {
|
|
3867
4632
|
if (this._heartbeatTimer !== null)
|
|
3868
4633
|
return;
|
|
3869
|
-
const
|
|
3870
|
-
if (
|
|
4634
|
+
const rawIntervalSeconds = Number(this._sessionOptions.heartbeat_interval ?? DEFAULT_SESSION_OPTIONS.heartbeat_interval);
|
|
4635
|
+
if (!Number.isFinite(rawIntervalSeconds) || rawIntervalSeconds <= 0)
|
|
3871
4636
|
return;
|
|
4637
|
+
const interval = Math.max(rawIntervalSeconds, 30) * 1000;
|
|
3872
4638
|
// M25: 把连续失败阈值从 3 次收窄到 2 次。既能容忍一次网络抖动/GC 暂停,
|
|
3873
4639
|
// 又把半开连接的检测延迟从 3 个心跳周期降到 2 个,避免 RPC 长时间挂起。
|
|
3874
4640
|
// 真正的 socket 死亡由 ws.on('close') 立即触发 _handleTransportDisconnect,
|
|
@@ -3897,32 +4663,35 @@ export class AUNClient {
|
|
|
3897
4663
|
_startTokenRefresh() {
|
|
3898
4664
|
if (this._tokenRefreshTimer !== null)
|
|
3899
4665
|
return;
|
|
3900
|
-
const
|
|
4666
|
+
const rawLead = Number(this._sessionOptions.token_refresh_before ?? DEFAULT_SESSION_OPTIONS.token_refresh_before);
|
|
4667
|
+
const lead = Number.isFinite(rawLead) && rawLead > 0
|
|
4668
|
+
? rawLead
|
|
4669
|
+
: DEFAULT_SESSION_OPTIONS.token_refresh_before;
|
|
4670
|
+
const scheduleRefresh = (delayMs = TOKEN_REFRESH_CHECK_INTERVAL_MS) => {
|
|
3901
4671
|
if (this._closing)
|
|
3902
4672
|
return;
|
|
3903
|
-
const lead = this._sessionOptions.token_refresh_before;
|
|
3904
|
-
const minimumDelay = 1000;
|
|
3905
|
-
if (this._state !== 'connected' || !this._gatewayUrl) {
|
|
3906
|
-
// 非连接状态下使用指数退避,避免 1s 轮询浪费 CPU
|
|
3907
|
-
this._tokenDisconnectedRetries++;
|
|
3908
|
-
const backoff = Math.min(minimumDelay * Math.pow(2, this._tokenDisconnectedRetries), 60_000);
|
|
3909
|
-
this._tokenRefreshTimer = globalThis.setTimeout(scheduleRefresh, backoff);
|
|
3910
|
-
return;
|
|
3911
|
-
}
|
|
3912
|
-
// 连接恢复后重置退避计数器
|
|
3913
|
-
this._tokenDisconnectedRetries = 0;
|
|
3914
|
-
let identity = this._identity;
|
|
3915
|
-
if (!identity) {
|
|
3916
|
-
this._tokenRefreshTimer = globalThis.setTimeout(scheduleRefresh, minimumDelay);
|
|
3917
|
-
return;
|
|
3918
|
-
}
|
|
3919
|
-
const expiresAt = this._auth.getAccessTokenExpiry(identity);
|
|
3920
|
-
if (expiresAt === null) {
|
|
3921
|
-
this._tokenRefreshTimer = globalThis.setTimeout(scheduleRefresh, minimumDelay);
|
|
3922
|
-
return;
|
|
3923
|
-
}
|
|
3924
|
-
const delay = Math.max((expiresAt - lead) * 1000 - Date.now(), minimumDelay);
|
|
3925
4673
|
this._tokenRefreshTimer = globalThis.setTimeout(async () => {
|
|
4674
|
+
if (this._closing)
|
|
4675
|
+
return;
|
|
4676
|
+
this._tokenRefreshTimer = null;
|
|
4677
|
+
if (this._state !== 'connected' || !this._gatewayUrl) {
|
|
4678
|
+
scheduleRefresh();
|
|
4679
|
+
return;
|
|
4680
|
+
}
|
|
4681
|
+
let identity = this._identity;
|
|
4682
|
+
if (!identity) {
|
|
4683
|
+
scheduleRefresh();
|
|
4684
|
+
return;
|
|
4685
|
+
}
|
|
4686
|
+
const expiresAt = this._auth.getAccessTokenExpiry(identity);
|
|
4687
|
+
if (expiresAt === null) {
|
|
4688
|
+
scheduleRefresh();
|
|
4689
|
+
return;
|
|
4690
|
+
}
|
|
4691
|
+
if ((expiresAt - Date.now() / 1000) > lead) {
|
|
4692
|
+
scheduleRefresh();
|
|
4693
|
+
return;
|
|
4694
|
+
}
|
|
3926
4695
|
if (this._state !== 'connected' || !this._gatewayUrl || this._closing) {
|
|
3927
4696
|
scheduleRefresh();
|
|
3928
4697
|
return;
|
|
@@ -3960,9 +4729,9 @@ export class AUNClient {
|
|
|
3960
4729
|
}
|
|
3961
4730
|
}
|
|
3962
4731
|
scheduleRefresh();
|
|
3963
|
-
},
|
|
4732
|
+
}, delayMs);
|
|
3964
4733
|
};
|
|
3965
|
-
scheduleRefresh();
|
|
4734
|
+
scheduleRefresh(0);
|
|
3966
4735
|
}
|
|
3967
4736
|
/** Prekey 轮换定时器:定期检查本地 prekey 数量,不足时自动补充上传 */
|
|
3968
4737
|
_startPrekeyRefresh() {
|
|
@@ -4046,10 +4815,12 @@ export class AUNClient {
|
|
|
4046
4815
|
}
|
|
4047
4816
|
if (method === 'group.thought.put' || method === 'group.thought.get'
|
|
4048
4817
|
|| method === 'message.thought.put' || method === 'message.thought.get') {
|
|
4049
|
-
const
|
|
4050
|
-
const
|
|
4051
|
-
|
|
4052
|
-
|
|
4818
|
+
const context = isJsonObject(params.context) ? params.context : null;
|
|
4819
|
+
const contextType = String(context?.type ?? '').trim();
|
|
4820
|
+
const contextId = String(context?.id ?? '').trim();
|
|
4821
|
+
const hasContext = contextType.length > 0 && contextId.length > 0;
|
|
4822
|
+
if (!hasContext) {
|
|
4823
|
+
throw new ValidationError(`${method} requires context.type + context.id`);
|
|
4053
4824
|
}
|
|
4054
4825
|
}
|
|
4055
4826
|
if (method === 'group.thought.get' && !String(params.sender_aid ?? '').trim()) {
|
|
@@ -4280,6 +5051,69 @@ export class AUNClient {
|
|
|
4280
5051
|
this._reconnectActive = false;
|
|
4281
5052
|
this._reconnectAbort = null;
|
|
4282
5053
|
}
|
|
5054
|
+
// ── Named Group(命名群)高层 API ────────────────────────────
|
|
5055
|
+
/**
|
|
5056
|
+
* 创建命名群:本地生成 P-256 keypair,调用 group.create 传入 public_key,
|
|
5057
|
+
* 服务端签发群 AID 证书,返回后将证书和私钥存入 keystore。
|
|
5058
|
+
*/
|
|
5059
|
+
async createNamedGroup(groupName, opts = {}) {
|
|
5060
|
+
const cp = new CryptoProvider();
|
|
5061
|
+
const identity = await cp.generateIdentity();
|
|
5062
|
+
const params = {};
|
|
5063
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
5064
|
+
params[k] = v;
|
|
5065
|
+
}
|
|
5066
|
+
params.group_name = groupName;
|
|
5067
|
+
params.public_key = identity.public_key_der_b64;
|
|
5068
|
+
params.curve = 'P-256';
|
|
5069
|
+
const result = await this.call('group.create', params);
|
|
5070
|
+
const groupInfo = result?.group;
|
|
5071
|
+
const aidCert = result?.aid_cert;
|
|
5072
|
+
const groupAid = String(groupInfo?.group_aid ?? '');
|
|
5073
|
+
if (groupAid && aidCert) {
|
|
5074
|
+
await this._keystore.saveIdentity(groupAid, {
|
|
5075
|
+
private_key_pem: identity.private_key_pem,
|
|
5076
|
+
public_key: identity.public_key_der_b64,
|
|
5077
|
+
curve: 'P-256',
|
|
5078
|
+
type: 'group_identity',
|
|
5079
|
+
});
|
|
5080
|
+
const certPem = String(aidCert.cert ?? '');
|
|
5081
|
+
if (certPem) {
|
|
5082
|
+
await this._keystore.saveCert(groupAid, certPem);
|
|
5083
|
+
}
|
|
5084
|
+
}
|
|
5085
|
+
return result;
|
|
5086
|
+
}
|
|
5087
|
+
/**
|
|
5088
|
+
* 为已有普通群绑定命名 AID(升级为命名群)。
|
|
5089
|
+
*/
|
|
5090
|
+
async bindGroupAid(groupId, groupName) {
|
|
5091
|
+
const cp = new CryptoProvider();
|
|
5092
|
+
const identity = await cp.generateIdentity();
|
|
5093
|
+
const params = {
|
|
5094
|
+
group_id: groupId,
|
|
5095
|
+
group_name: groupName,
|
|
5096
|
+
public_key: identity.public_key_der_b64,
|
|
5097
|
+
curve: 'P-256',
|
|
5098
|
+
};
|
|
5099
|
+
const result = await this.call('group.bind_aid', params);
|
|
5100
|
+
const groupInfo = result?.group;
|
|
5101
|
+
const aidCert = result?.aid_cert;
|
|
5102
|
+
const groupAid = String(groupInfo?.group_aid ?? '');
|
|
5103
|
+
if (groupAid && aidCert) {
|
|
5104
|
+
await this._keystore.saveIdentity(groupAid, {
|
|
5105
|
+
private_key_pem: identity.private_key_pem,
|
|
5106
|
+
public_key: identity.public_key_der_b64,
|
|
5107
|
+
curve: 'P-256',
|
|
5108
|
+
type: 'group_identity',
|
|
5109
|
+
});
|
|
5110
|
+
const certPem = String(aidCert.cert ?? '');
|
|
5111
|
+
if (certPem) {
|
|
5112
|
+
await this._keystore.saveCert(groupAid, certPem);
|
|
5113
|
+
}
|
|
5114
|
+
}
|
|
5115
|
+
return result;
|
|
5116
|
+
}
|
|
4283
5117
|
/** 判断是否应重试重连 */
|
|
4284
5118
|
_shouldRetryReconnect(error) {
|
|
4285
5119
|
if (error instanceof AuthError) {
|