@agentunion/fastaun 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.js +4 -1
- package/dist/auth.js.map +1 -1
- package/dist/client.d.ts +45 -7
- package/dist/client.js +1021 -180
- package/dist/client.js.map +1 -1
- package/dist/e2ee-group.d.ts +54 -2
- package/dist/e2ee-group.js +314 -36
- package/dist/e2ee-group.js.map +1 -1
- package/dist/e2ee.d.ts +20 -1
- package/dist/e2ee.js +258 -35
- package/dist/e2ee.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/keystore/aid-db.d.ts +10 -0
- package/dist/keystore/aid-db.js +45 -2
- package/dist/keystore/aid-db.js.map +1 -1
- package/dist/keystore/file.d.ts +10 -0
- package/dist/keystore/file.js +73 -0
- package/dist/keystore/file.js.map +1 -1
- package/dist/keystore/index.d.ts +24 -0
- package/dist/namespaces/auth.d.ts +19 -0
- package/dist/namespaces/auth.js +185 -0
- package/dist/namespaces/auth.js.map +1 -1
- package/dist/namespaces/meta.d.ts +75 -0
- package/dist/namespaces/meta.js +464 -0
- package/dist/namespaces/meta.js.map +1 -0
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -19,7 +19,7 @@ import { configFromMap, getDeviceId, normalizeInstanceId } from './config.js';
|
|
|
19
19
|
import { CryptoProvider } from './crypto.js';
|
|
20
20
|
import { GatewayDiscovery } from './discovery.js';
|
|
21
21
|
import { E2EEManager } from './e2ee.js';
|
|
22
|
-
import { GroupE2EEManager, computeMembershipCommitment, storeGroupSecret, buildKeyDistribution, buildKeyRequest, buildMembershipManifest, signMembershipManifest, } from './e2ee-group.js';
|
|
22
|
+
import { GroupE2EEManager, computeMembershipCommitment, computeStateHash, storeGroupSecret, storeGroupSecretEpoch, buildKeyDistribution, buildKeyRequest, buildMembershipManifest, signMembershipManifest, verifyEpochChain, } from './e2ee-group.js';
|
|
23
23
|
import { AUNError, AuthError, ConnectionError, E2EEError, PermissionError, StateError, TimeoutError, ValidationError, } from './errors.js';
|
|
24
24
|
import { EventDispatcher } from './events.js';
|
|
25
25
|
import { FileKeyStore } from './keystore/file.js';
|
|
@@ -27,6 +27,7 @@ import { AUNLogger } from './logger.js';
|
|
|
27
27
|
import { SQLiteBackup } from './keystore/sqlite-backup.js';
|
|
28
28
|
import { AuthNamespace } from './namespaces/auth.js';
|
|
29
29
|
import { CustodyNamespace } from './namespaces/custody.js';
|
|
30
|
+
import { MetaNamespace } from './namespaces/meta.js';
|
|
30
31
|
import { RPCTransport } from './transport.js';
|
|
31
32
|
import { AuthFlow } from './auth.js';
|
|
32
33
|
import { SeqTracker } from './seq-tracker.js';
|
|
@@ -82,7 +83,7 @@ const INTERNAL_ONLY_METHODS = new Set([
|
|
|
82
83
|
const DEFAULT_SESSION_OPTIONS = {
|
|
83
84
|
auto_reconnect: true,
|
|
84
85
|
heartbeat_interval: 30.0,
|
|
85
|
-
token_refresh_before:
|
|
86
|
+
token_refresh_before: 1800.0,
|
|
86
87
|
retry: {
|
|
87
88
|
initial_delay: 1.0,
|
|
88
89
|
max_delay: 64.0,
|
|
@@ -97,6 +98,7 @@ const DEFAULT_SESSION_OPTIONS = {
|
|
|
97
98
|
};
|
|
98
99
|
const RECONNECT_MIN_BASE_DELAY_MS = 1_000;
|
|
99
100
|
const RECONNECT_MAX_BASE_DELAY_MS = 64_000;
|
|
101
|
+
const TOKEN_REFRESH_CHECK_INTERVAL_MS = 30_000;
|
|
100
102
|
const GROUP_ROTATION_LEASE_MS = 120_000;
|
|
101
103
|
const GROUP_ROTATION_RETRY_MAX_DELAY_MS = 300_000;
|
|
102
104
|
const PENDING_DECRYPT_LIMIT = 100;
|
|
@@ -138,9 +140,16 @@ const SIGNED_METHODS = new Set([
|
|
|
138
140
|
'group.resources.delete', 'group.resources.request_add',
|
|
139
141
|
'group.resources.direct_add', 'group.resources.approve_request',
|
|
140
142
|
'group.resources.reject_request',
|
|
143
|
+
'group.commit_state',
|
|
144
|
+
'group.e2ee.begin_rotation', 'group.e2ee.commit_rotation',
|
|
145
|
+
'group.e2ee.abort_rotation',
|
|
146
|
+
'group.ban', 'group.unban',
|
|
147
|
+
'group.dissolve', 'group.suspend', 'group.resume',
|
|
141
148
|
]);
|
|
142
|
-
/** peer 证书缓存 TTL(
|
|
143
|
-
const PEER_CERT_CACHE_TTL =
|
|
149
|
+
/** peer 证书缓存 TTL(1 小时) */
|
|
150
|
+
const PEER_CERT_CACHE_TTL = 3600;
|
|
151
|
+
const PEER_PREKEYS_CACHE_TTL = 3600;
|
|
152
|
+
const PREKEY_FALLBACK_DEVICE_ID = 'aun_device_id';
|
|
144
153
|
function isGroupServiceAid(value) {
|
|
145
154
|
const text = String(value ?? '').trim();
|
|
146
155
|
if (!text.includes('.'))
|
|
@@ -165,6 +174,65 @@ function isPeerPrekeyResponse(value) {
|
|
|
165
174
|
return false;
|
|
166
175
|
return candidate.prekey === undefined || isPeerPrekeyMaterial(candidate.prekey);
|
|
167
176
|
}
|
|
177
|
+
function normalizePeerPrekeys(prekeys) {
|
|
178
|
+
const normalized = [];
|
|
179
|
+
for (const item of prekeys) {
|
|
180
|
+
if (!isPeerPrekeyMaterial(item))
|
|
181
|
+
continue;
|
|
182
|
+
const prekeyId = item.prekey_id.trim();
|
|
183
|
+
const publicKey = item.public_key.trim();
|
|
184
|
+
const signature = item.signature.trim();
|
|
185
|
+
if (!prekeyId || !publicKey || !signature)
|
|
186
|
+
continue;
|
|
187
|
+
const deviceId = String(item.device_id ?? '').trim();
|
|
188
|
+
const certFingerprint = String(item.cert_fingerprint ?? '').trim().toLowerCase();
|
|
189
|
+
const candidate = {
|
|
190
|
+
...item,
|
|
191
|
+
prekey_id: prekeyId,
|
|
192
|
+
public_key: publicKey,
|
|
193
|
+
signature,
|
|
194
|
+
device_id: deviceId,
|
|
195
|
+
};
|
|
196
|
+
if (certFingerprint) {
|
|
197
|
+
candidate.cert_fingerprint = certFingerprint;
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
delete candidate.cert_fingerprint;
|
|
201
|
+
}
|
|
202
|
+
normalized.push(candidate);
|
|
203
|
+
}
|
|
204
|
+
if (normalized.length === 0)
|
|
205
|
+
return [];
|
|
206
|
+
if (normalized.length === 1) {
|
|
207
|
+
if (!String(normalized[0].device_id ?? '').trim()) {
|
|
208
|
+
normalized[0].device_id = PREKEY_FALLBACK_DEVICE_ID;
|
|
209
|
+
}
|
|
210
|
+
return normalized;
|
|
211
|
+
}
|
|
212
|
+
const seen = new Set();
|
|
213
|
+
const filtered = [];
|
|
214
|
+
for (const item of normalized) {
|
|
215
|
+
const deviceId = String(item.device_id ?? '').trim();
|
|
216
|
+
if (!deviceId || deviceId === PREKEY_FALLBACK_DEVICE_ID || seen.has(deviceId))
|
|
217
|
+
continue;
|
|
218
|
+
seen.add(deviceId);
|
|
219
|
+
filtered.push(item);
|
|
220
|
+
}
|
|
221
|
+
return filtered;
|
|
222
|
+
}
|
|
223
|
+
/** 判断加密失败是否由过期的对端证书或 prekey 引起,可通过刷新缓存重试 */
|
|
224
|
+
function isRetryablePeerMaterialError(error) {
|
|
225
|
+
const localCode = String(error?.localCode ?? error?.code ?? '').trim();
|
|
226
|
+
if (localCode === 'PEER_CERT_FINGERPRINT_MISMATCH'
|
|
227
|
+
|| localCode === 'PREKEY_CERT_FINGERPRINT_MISMATCH'
|
|
228
|
+
|| localCode === 'PREKEY_SIGNATURE_VERIFY_FAILED') {
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
const message = error instanceof Error ? error.message : String(error ?? '');
|
|
232
|
+
return message.includes('peer cert fingerprint mismatch for ')
|
|
233
|
+
|| message.includes('prekey cert fingerprint mismatch')
|
|
234
|
+
|| message.includes('prekey 签名验证失败');
|
|
235
|
+
}
|
|
168
236
|
function formatCaughtError(error) {
|
|
169
237
|
return error instanceof Error ? error : String(error);
|
|
170
238
|
}
|
|
@@ -271,6 +339,8 @@ export class AUNClient {
|
|
|
271
339
|
auth;
|
|
272
340
|
/** AID 托管命名空间 */
|
|
273
341
|
custody;
|
|
342
|
+
/** Meta 命名空间(心跳、状态、信任根管理) */
|
|
343
|
+
meta;
|
|
274
344
|
/** 会话参数(重连用) */
|
|
275
345
|
_sessionParams = null;
|
|
276
346
|
/** 会话选项 */
|
|
@@ -302,6 +372,8 @@ export class AUNClient {
|
|
|
302
372
|
_groupEpochRotationInflight = new Set();
|
|
303
373
|
_groupEpochRecoveryInflight = new Map();
|
|
304
374
|
_groupMembershipRotationDone = new Set();
|
|
375
|
+
/** 群密钥 backfill 去重:已完成/进行中的 key 集合,防止重复分发 */
|
|
376
|
+
_groupMemberKeyBackfillDone = new Set();
|
|
305
377
|
_groupEpochRotationRetryTimers = new Map();
|
|
306
378
|
// ── 后台任务定时器 ──────────────────────────────────────────
|
|
307
379
|
_heartbeatTimer = null;
|
|
@@ -368,12 +440,15 @@ export class AUNClient {
|
|
|
368
440
|
});
|
|
369
441
|
this.auth = new AuthNamespace(this);
|
|
370
442
|
this.custody = new CustodyNamespace(this);
|
|
443
|
+
this.meta = new MetaNamespace(this);
|
|
371
444
|
// 内部订阅:推送消息自动解密后 re-publish 给用户
|
|
372
445
|
this._dispatcher.subscribe('_raw.message.received', (data) => this._onRawMessageReceived(data));
|
|
373
446
|
// 群组消息推送:自动解密后 re-publish
|
|
374
447
|
this._dispatcher.subscribe('_raw.group.message_created', (data) => this._onRawGroupMessageCreated(data));
|
|
375
448
|
// 群组变更事件:拦截处理成员变更触发的 epoch 轮换,然后透传
|
|
376
449
|
this._dispatcher.subscribe('_raw.group.changed', (data) => this._onRawGroupChanged(data));
|
|
450
|
+
// 群组状态提交事件:验证 state_hash 链并更新本地存储
|
|
451
|
+
this._dispatcher.subscribe('_raw.group.state_committed', (data) => this._onGroupStateCommitted(data));
|
|
377
452
|
// 其他事件直接透传
|
|
378
453
|
for (const evt of ['message.recalled', 'message.ack', 'storage.object_changed']) {
|
|
379
454
|
this._dispatcher.subscribe(`_raw.${evt}`, (data) => this._dispatcher.publish(evt, data));
|
|
@@ -417,6 +492,7 @@ export class AUNClient {
|
|
|
417
492
|
if (this._state !== 'idle' && this._state !== 'closed' && this._state !== 'disconnected') {
|
|
418
493
|
throw new StateError(`connect not allowed in state ${this._state}`);
|
|
419
494
|
}
|
|
495
|
+
this._state = 'connecting';
|
|
420
496
|
const params = { ...auth };
|
|
421
497
|
if (options)
|
|
422
498
|
Object.assign(params, options);
|
|
@@ -426,7 +502,16 @@ export class AUNClient {
|
|
|
426
502
|
const callTimeoutSec = this._sessionOptions.timeouts.call;
|
|
427
503
|
this._transport.setTimeout(callTimeoutSec != null ? callTimeoutSec * 1000 : 10_000);
|
|
428
504
|
this._closing = false;
|
|
429
|
-
|
|
505
|
+
try {
|
|
506
|
+
await this._connectOnce(normalized, false);
|
|
507
|
+
}
|
|
508
|
+
catch (err) {
|
|
509
|
+
// 连接失败时回退状态,允许重试
|
|
510
|
+
if (this._state === 'connecting' || this._state === 'authenticating') {
|
|
511
|
+
this._state = 'disconnected';
|
|
512
|
+
}
|
|
513
|
+
throw err;
|
|
514
|
+
}
|
|
430
515
|
}
|
|
431
516
|
/** 关闭连接 */
|
|
432
517
|
async close() {
|
|
@@ -518,6 +603,8 @@ export class AUNClient {
|
|
|
518
603
|
if (encrypt) {
|
|
519
604
|
return await this._sendEncrypted(p);
|
|
520
605
|
}
|
|
606
|
+
delete p.protected_headers;
|
|
607
|
+
delete p.headers;
|
|
521
608
|
}
|
|
522
609
|
// 自动加密:group.send 默认加密(encrypt 默认 True)
|
|
523
610
|
if (method === 'group.send') {
|
|
@@ -526,6 +613,8 @@ export class AUNClient {
|
|
|
526
613
|
if (encrypt) {
|
|
527
614
|
return await this._sendGroupEncrypted(p);
|
|
528
615
|
}
|
|
616
|
+
delete p.protected_headers;
|
|
617
|
+
delete p.headers;
|
|
529
618
|
}
|
|
530
619
|
if (method === 'group.thought.put') {
|
|
531
620
|
const encrypt = p.encrypt ?? true;
|
|
@@ -667,8 +756,11 @@ export class AUNClient {
|
|
|
667
756
|
const groupId = this._extractGroupIdFromResult(result) || String(p.group_id ?? '');
|
|
668
757
|
if (groupId && this._membershipRotationChanged(method, result)) {
|
|
669
758
|
const expectedEpoch = this._membershipRotationExpectedEpoch(result);
|
|
759
|
+
// 自加入方法(request_join/use_invite_code)需要 allowMember=true,
|
|
760
|
+
// 因为新成员角色是 member,必须允许 member 参与 leader 选举。
|
|
761
|
+
const allowMember = method === 'group.request_join' || method === 'group.use_invite_code';
|
|
670
762
|
// P0-12: await rotation 完成(带超时兜底),确保后续 group.send 使用新 epoch
|
|
671
|
-
const rotationPromise = this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch);
|
|
763
|
+
const rotationPromise = this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch, allowMember);
|
|
672
764
|
const timeoutPromise = new Promise((resolve) => globalThis.setTimeout(resolve, 5000));
|
|
673
765
|
await Promise.race([rotationPromise, timeoutPromise]).catch((exc) => _clientLog('warn', 'membership RPC epoch rotation fallback failed: %s', formatCaughtError(exc)));
|
|
674
766
|
}
|
|
@@ -698,6 +790,17 @@ export class AUNClient {
|
|
|
698
790
|
this._dispatcher.unsubscribe(event, handler);
|
|
699
791
|
}
|
|
700
792
|
// ── E2EE 加密发送 ────────────────────────────────────────
|
|
793
|
+
_protectedHeadersFromParams(params) {
|
|
794
|
+
const value = params.protected_headers ?? params.headers;
|
|
795
|
+
if (value == null)
|
|
796
|
+
return null;
|
|
797
|
+
if (isJsonObject(value))
|
|
798
|
+
return value;
|
|
799
|
+
if (typeof value === 'object' && typeof value.toObject === 'function') {
|
|
800
|
+
return value;
|
|
801
|
+
}
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
701
804
|
/** 自动加密并发送 P2P 消息 */
|
|
702
805
|
async _sendEncrypted(params) {
|
|
703
806
|
const toAid = String(params.to ?? '');
|
|
@@ -709,51 +812,78 @@ export class AUNClient {
|
|
|
709
812
|
throw new ValidationError('message.send payload must be an object when encrypt=true');
|
|
710
813
|
}
|
|
711
814
|
const persistRequired = Boolean(params.persist_required || params.durable);
|
|
815
|
+
const protectedHeaders = this._protectedHeadersFromParams(params);
|
|
712
816
|
// 惰性同步:首次发送 P2P 消息时先 pull 一次
|
|
713
817
|
if (!this._p2pSynced) {
|
|
714
818
|
await this._lazySyncP2p();
|
|
715
819
|
}
|
|
716
|
-
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
820
|
+
// 内部发送逻辑,refreshPeerMaterial=true 时强制清缓存重新拉取对端材料
|
|
821
|
+
const sendAttempt = async (refreshPeerMaterial = false) => {
|
|
822
|
+
const recipientPrekeys = refreshPeerMaterial
|
|
823
|
+
? await this._refreshPeerPrekeys(toAid)
|
|
824
|
+
: await this._fetchPeerPrekeys(toAid);
|
|
825
|
+
const selfSyncCopies = await this._buildSelfSyncCopies({
|
|
826
|
+
logicalToAid: toAid,
|
|
827
|
+
payload,
|
|
828
|
+
messageId,
|
|
829
|
+
timestamp,
|
|
830
|
+
protectedHeaders,
|
|
831
|
+
});
|
|
832
|
+
// 多设备过滤:只保留有有效 device_id 的可路由 prekey,
|
|
833
|
+
// 占位符 PREKEY_FALLBACK_DEVICE_ID 表示服务端未分配真实设备 ID,不可用于多设备路由
|
|
834
|
+
const routablePrekeys = recipientPrekeys.filter(pk => {
|
|
835
|
+
const did = String(pk.device_id ?? '').trim();
|
|
836
|
+
return did && did !== PREKEY_FALLBACK_DEVICE_ID;
|
|
837
|
+
});
|
|
838
|
+
const canUseMultiDevice = routablePrekeys.length > 0
|
|
839
|
+
&& (routablePrekeys.length > 1 || selfSyncCopies.length > 0);
|
|
840
|
+
if (!canUseMultiDevice) {
|
|
841
|
+
return await this._sendEncryptedSingle({
|
|
842
|
+
toAid,
|
|
843
|
+
payload,
|
|
844
|
+
messageId,
|
|
845
|
+
timestamp,
|
|
846
|
+
prekey: routablePrekeys[0] ?? recipientPrekeys[0],
|
|
847
|
+
persistRequired,
|
|
848
|
+
protectedHeaders,
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
const recipientCopies = await this._buildRecipientDeviceCopies({
|
|
725
852
|
toAid,
|
|
726
853
|
payload,
|
|
727
854
|
messageId,
|
|
728
855
|
timestamp,
|
|
729
|
-
|
|
730
|
-
|
|
856
|
+
prekeys: routablePrekeys,
|
|
857
|
+
protectedHeaders,
|
|
731
858
|
});
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
const sendParams = {
|
|
741
|
-
to: toAid,
|
|
742
|
-
payload: {
|
|
859
|
+
const sendParams = {
|
|
860
|
+
to: toAid,
|
|
861
|
+
payload: {
|
|
862
|
+
type: 'e2ee.multi_device',
|
|
863
|
+
logical_message_id: messageId,
|
|
864
|
+
recipient_copies: recipientCopies,
|
|
865
|
+
self_copies: selfSyncCopies,
|
|
866
|
+
},
|
|
743
867
|
type: 'e2ee.multi_device',
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
868
|
+
encrypted: true,
|
|
869
|
+
message_id: messageId,
|
|
870
|
+
timestamp,
|
|
871
|
+
};
|
|
872
|
+
if (persistRequired) {
|
|
873
|
+
sendParams.persist_required = true;
|
|
874
|
+
}
|
|
875
|
+
return await this._transport.call('message.send', sendParams);
|
|
752
876
|
};
|
|
753
|
-
|
|
754
|
-
|
|
877
|
+
// 首次尝试(使用缓存);若对端证书/prekey 过期导致指纹不匹配,清缓存后重试一次
|
|
878
|
+
try {
|
|
879
|
+
return await sendAttempt(false);
|
|
755
880
|
}
|
|
756
|
-
|
|
881
|
+
catch (exc) {
|
|
882
|
+
if (!isRetryablePeerMaterialError(exc))
|
|
883
|
+
throw exc;
|
|
884
|
+
_clientLog('warn', 'peer cert/prekey mismatch for %s, refreshing and retrying once', toAid);
|
|
885
|
+
}
|
|
886
|
+
return await sendAttempt(true);
|
|
757
887
|
}
|
|
758
888
|
async _sendEncryptedSingle(opts) {
|
|
759
889
|
let prekey = opts.prekey ?? null;
|
|
@@ -769,6 +899,7 @@ export class AUNClient {
|
|
|
769
899
|
prekey,
|
|
770
900
|
messageId: opts.messageId,
|
|
771
901
|
timestamp: opts.timestamp,
|
|
902
|
+
protectedHeaders: opts.protectedHeaders,
|
|
772
903
|
});
|
|
773
904
|
this._ensureEncryptResult(opts.toAid, encryptResult);
|
|
774
905
|
const sendParams = {
|
|
@@ -787,7 +918,7 @@ export class AUNClient {
|
|
|
787
918
|
async _buildRecipientDeviceCopies(opts) {
|
|
788
919
|
const recipientCopies = [];
|
|
789
920
|
const certCache = new Map();
|
|
790
|
-
for (const prekey of opts.prekeys) {
|
|
921
|
+
for (const prekey of normalizePeerPrekeys(opts.prekeys)) {
|
|
791
922
|
const deviceId = String(prekey.device_id ?? '').trim();
|
|
792
923
|
const peerCertFingerprint = String(prekey.cert_fingerprint ?? '').trim().toLowerCase();
|
|
793
924
|
const cacheKey = peerCertFingerprint || '__default__';
|
|
@@ -803,6 +934,7 @@ export class AUNClient {
|
|
|
803
934
|
prekey,
|
|
804
935
|
messageId: opts.messageId,
|
|
805
936
|
timestamp: opts.timestamp,
|
|
937
|
+
protectedHeaders: opts.protectedHeaders,
|
|
806
938
|
});
|
|
807
939
|
this._ensureEncryptResult(opts.toAid, encryptResult);
|
|
808
940
|
recipientCopies.push({
|
|
@@ -845,7 +977,7 @@ export class AUNClient {
|
|
|
845
977
|
if (!myAid) {
|
|
846
978
|
return [];
|
|
847
979
|
}
|
|
848
|
-
const prekeys = await this._fetchPeerPrekeys(myAid);
|
|
980
|
+
const prekeys = normalizePeerPrekeys(await this._fetchPeerPrekeys(myAid));
|
|
849
981
|
if (prekeys.length === 0) {
|
|
850
982
|
return [];
|
|
851
983
|
}
|
|
@@ -855,7 +987,15 @@ export class AUNClient {
|
|
|
855
987
|
if (deviceId === this._deviceId) {
|
|
856
988
|
continue;
|
|
857
989
|
}
|
|
858
|
-
|
|
990
|
+
let peerCertPem;
|
|
991
|
+
try {
|
|
992
|
+
peerCertPem = await this._resolveSelfCopyPeerCert(String(prekey.cert_fingerprint ?? '').trim().toLowerCase() || undefined);
|
|
993
|
+
}
|
|
994
|
+
catch (e) {
|
|
995
|
+
// 旧设备的 prekey 可能携带已轮换的证书指纹,跳过该设备的自同步副本
|
|
996
|
+
_clientLog('warn', `self-sync 跳过设备 ${deviceId}: 证书解析失败 (${e}),可能是旧 prekey`);
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
859
999
|
const [envelope, encryptResult] = this._encryptCopyPayload({
|
|
860
1000
|
logicalToAid: opts.logicalToAid,
|
|
861
1001
|
payload: opts.payload,
|
|
@@ -863,6 +1003,7 @@ export class AUNClient {
|
|
|
863
1003
|
prekey,
|
|
864
1004
|
messageId: opts.messageId,
|
|
865
1005
|
timestamp: opts.timestamp,
|
|
1006
|
+
protectedHeaders: opts.protectedHeaders,
|
|
866
1007
|
});
|
|
867
1008
|
this._ensureEncryptResult(myAid, encryptResult);
|
|
868
1009
|
copies.push({
|
|
@@ -873,7 +1014,7 @@ export class AUNClient {
|
|
|
873
1014
|
return copies;
|
|
874
1015
|
}
|
|
875
1016
|
_encryptCopyPayload(opts) {
|
|
876
|
-
const [envelope, encryptResult] = this._e2ee.encryptOutbound(opts.logicalToAid, opts.payload, opts.peerCertPem, opts.prekey ?? null, opts.messageId, opts.timestamp);
|
|
1017
|
+
const [envelope, encryptResult] = this._e2ee.encryptOutbound(opts.logicalToAid, opts.payload, opts.peerCertPem, opts.prekey ?? null, opts.messageId, opts.timestamp, opts.protectedHeaders, opts.context ?? null);
|
|
877
1018
|
return [envelope, encryptResult];
|
|
878
1019
|
}
|
|
879
1020
|
_ensureEncryptResult(toAid, encryptResult) {
|
|
@@ -905,7 +1046,7 @@ export class AUNClient {
|
|
|
905
1046
|
return await this._callGroupEncryptedRpc('group.thought.put', params, {
|
|
906
1047
|
idField: 'thought_id',
|
|
907
1048
|
idPrefix: 'gt',
|
|
908
|
-
extraFields: ['
|
|
1049
|
+
extraFields: ['context'],
|
|
909
1050
|
});
|
|
910
1051
|
}
|
|
911
1052
|
async _putMessageThoughtEncrypted(params) {
|
|
@@ -930,6 +1071,8 @@ export class AUNClient {
|
|
|
930
1071
|
prekey,
|
|
931
1072
|
messageId: thoughtId,
|
|
932
1073
|
timestamp,
|
|
1074
|
+
protectedHeaders: this._protectedHeadersFromParams(params),
|
|
1075
|
+
context: isJsonObject(params.context) ? params.context : null,
|
|
933
1076
|
});
|
|
934
1077
|
this._ensureEncryptResult(toAid, encryptResult);
|
|
935
1078
|
const sendParams = {
|
|
@@ -939,8 +1082,9 @@ export class AUNClient {
|
|
|
939
1082
|
encrypted: true,
|
|
940
1083
|
thought_id: thoughtId,
|
|
941
1084
|
timestamp,
|
|
942
|
-
reply_to: params.reply_to,
|
|
943
1085
|
};
|
|
1086
|
+
if ('context' in params)
|
|
1087
|
+
sendParams.context = params.context;
|
|
944
1088
|
this._signClientOperation('message.thought.put', sendParams);
|
|
945
1089
|
return await this._transport.call('message.thought.put', sendParams);
|
|
946
1090
|
}
|
|
@@ -977,12 +1121,26 @@ export class AUNClient {
|
|
|
977
1121
|
await this._waitForGroupMembershipEpochFloor(groupId, 2000);
|
|
978
1122
|
const epochResult = await this._committedGroupEpochState(groupId);
|
|
979
1123
|
const committedEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
980
|
-
const envelope = committedEpoch > 0
|
|
981
|
-
? this._groupE2ee.encryptWithEpoch(groupId, await this._ensureCommittedGroupSecretForSend(groupId, committedEpoch, epochResult), payload)
|
|
982
|
-
: await this._groupE2ee.encrypt(groupId, payload);
|
|
983
1124
|
const operationId = String(params[options.idField] ?? '').trim()
|
|
984
1125
|
|| `${options.idPrefix}-${crypto.randomUUID()}`;
|
|
985
1126
|
const timestamp = Number(params.timestamp ?? Date.now());
|
|
1127
|
+
const protectedHeaders = this._protectedHeadersFromParams(params);
|
|
1128
|
+
const context = method === 'group.thought.put' && isJsonObject(params.context)
|
|
1129
|
+
? params.context
|
|
1130
|
+
: null;
|
|
1131
|
+
const envelope = committedEpoch > 0
|
|
1132
|
+
? this._groupE2ee.encryptWithEpoch(groupId, await this._ensureCommittedGroupSecretForSend(groupId, committedEpoch, epochResult), payload, {
|
|
1133
|
+
messageId: operationId,
|
|
1134
|
+
timestamp,
|
|
1135
|
+
protectedHeaders,
|
|
1136
|
+
context,
|
|
1137
|
+
})
|
|
1138
|
+
: await this._groupE2ee.encrypt(groupId, payload, {
|
|
1139
|
+
messageId: operationId,
|
|
1140
|
+
timestamp,
|
|
1141
|
+
protectedHeaders,
|
|
1142
|
+
context,
|
|
1143
|
+
});
|
|
986
1144
|
const sendParams = {
|
|
987
1145
|
group_id: groupId,
|
|
988
1146
|
payload: envelope,
|
|
@@ -1157,7 +1315,7 @@ export class AUNClient {
|
|
|
1157
1315
|
_clientLog('warn', 'group %s epoch precheck failed: %s', groupId, formatCaughtError(exc));
|
|
1158
1316
|
return;
|
|
1159
1317
|
}
|
|
1160
|
-
let serverEpoch = Number(epochResult.epoch ?? 0);
|
|
1318
|
+
let serverEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
1161
1319
|
if (!Number.isFinite(serverEpoch))
|
|
1162
1320
|
return;
|
|
1163
1321
|
const pending = isJsonObject(epochResult.pending_rotation) ? epochResult.pending_rotation : null;
|
|
@@ -1173,7 +1331,7 @@ export class AUNClient {
|
|
|
1173
1331
|
let effectiveLocalEpoch = initialLocalEpoch;
|
|
1174
1332
|
if (serverEpoch === 0 && effectiveLocalEpoch === 1) {
|
|
1175
1333
|
epochResult = await this._recoverInitialGroupEpochIfNeeded(groupId, effectiveLocalEpoch, epochResult);
|
|
1176
|
-
serverEpoch = Number(epochResult.epoch ?? 0);
|
|
1334
|
+
serverEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
1177
1335
|
if (serverEpoch === 0) {
|
|
1178
1336
|
throw new StateError(`group ${groupId} initial epoch sync has not completed; refuse to send with local epoch 1 while server epoch is 0`);
|
|
1179
1337
|
}
|
|
@@ -1185,7 +1343,9 @@ export class AUNClient {
|
|
|
1185
1343
|
while (Date.now() < waitDeadline) {
|
|
1186
1344
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
1187
1345
|
const refreshed = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
1188
|
-
const refreshedEpoch = isJsonObject(refreshed)
|
|
1346
|
+
const refreshedEpoch = isJsonObject(refreshed)
|
|
1347
|
+
? Number(refreshed.committed_epoch ?? refreshed.epoch ?? 0)
|
|
1348
|
+
: 0;
|
|
1189
1349
|
const currentLocal = await this._groupE2ee.currentEpoch(groupId);
|
|
1190
1350
|
if (Number.isFinite(refreshedEpoch) && refreshedEpoch > serverEpoch) {
|
|
1191
1351
|
epochResult = refreshed;
|
|
@@ -1201,7 +1361,7 @@ export class AUNClient {
|
|
|
1201
1361
|
}
|
|
1202
1362
|
}
|
|
1203
1363
|
_clientLog('warn', 'group %s local epoch=%s < server epoch=%s; requesting key recovery', groupId, effectiveLocalEpoch, serverEpoch);
|
|
1204
|
-
await this.
|
|
1364
|
+
await this._recoverGroupEpochKey(groupId, serverEpoch, '', 5000);
|
|
1205
1365
|
const deadline = Date.now() + 5000;
|
|
1206
1366
|
while (Date.now() < deadline) {
|
|
1207
1367
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
@@ -1274,8 +1434,23 @@ export class AUNClient {
|
|
|
1274
1434
|
async _ensureCommittedGroupSecretForSend(groupId, committedEpoch, epochResult) {
|
|
1275
1435
|
if (committedEpoch <= 0)
|
|
1276
1436
|
return committedEpoch;
|
|
1277
|
-
|
|
1278
|
-
|
|
1437
|
+
let secretData = await this._groupE2ee.loadSecret(groupId, committedEpoch);
|
|
1438
|
+
let committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
|
|
1439
|
+
if (await this._committedRotationMembershipGap(groupId, committedEpoch, committedRotation)) {
|
|
1440
|
+
const allowMember = await this._groupAllowsMemberEpochRotation(groupId);
|
|
1441
|
+
_clientLog('warn', '群 %s committed epoch %s 的成员快照与当前成员不一致,触发成员变更轮换修复', groupId, committedEpoch);
|
|
1442
|
+
await this._maybeLeadRotateGroupEpoch(groupId, `${groupId}:committed_membership_gap:aid:${this._aid}:epoch:${committedEpoch}`, committedEpoch, allowMember);
|
|
1443
|
+
const refreshed = await this._committedGroupEpochState(groupId);
|
|
1444
|
+
const refreshedCommittedEpoch = Number(refreshed.committed_epoch ?? refreshed.epoch ?? committedEpoch);
|
|
1445
|
+
if (Number.isFinite(refreshedCommittedEpoch) && refreshedCommittedEpoch > committedEpoch) {
|
|
1446
|
+
committedEpoch = refreshedCommittedEpoch;
|
|
1447
|
+
committedRotation = isJsonObject(refreshed.committed_rotation) ? refreshed.committed_rotation : null;
|
|
1448
|
+
secretData = await this._groupE2ee.loadSecret(groupId, committedEpoch);
|
|
1449
|
+
}
|
|
1450
|
+
if (await this._committedRotationMembershipGap(groupId, committedEpoch, committedRotation)) {
|
|
1451
|
+
throw new StateError(`group ${groupId} committed membership is stale at epoch ${committedEpoch}; key rotation repair has not completed`);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1279
1454
|
if (this._groupSecretMatchesCommittedRotation(secretData, committedRotation)) {
|
|
1280
1455
|
return committedEpoch;
|
|
1281
1456
|
}
|
|
@@ -1296,6 +1471,45 @@ export class AUNClient {
|
|
|
1296
1471
|
}
|
|
1297
1472
|
return committedEpoch;
|
|
1298
1473
|
}
|
|
1474
|
+
async _committedRotationMembershipGap(groupId, committedEpoch, committedRotation) {
|
|
1475
|
+
if (!this._aid || committedEpoch <= 0 || !committedRotation)
|
|
1476
|
+
return false;
|
|
1477
|
+
const expectedMembers = Array.isArray(committedRotation.expected_members)
|
|
1478
|
+
? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean).sort()
|
|
1479
|
+
: [];
|
|
1480
|
+
if (expectedMembers.length === 0)
|
|
1481
|
+
return false;
|
|
1482
|
+
try {
|
|
1483
|
+
const membersResult = await this.call('group.get_members', { group_id: groupId });
|
|
1484
|
+
const rawMembers = isJsonObject(membersResult)
|
|
1485
|
+
? (Array.isArray(membersResult.members) ? membersResult.members : membersResult.items)
|
|
1486
|
+
: [];
|
|
1487
|
+
if (!Array.isArray(rawMembers))
|
|
1488
|
+
return false;
|
|
1489
|
+
const activeMembers = rawMembers
|
|
1490
|
+
.filter((item) => isJsonObject(item))
|
|
1491
|
+
.map((item) => ({
|
|
1492
|
+
aid: String(item.aid ?? '').trim(),
|
|
1493
|
+
status: String(item.status ?? 'active').trim().toLowerCase(),
|
|
1494
|
+
}))
|
|
1495
|
+
.filter((item) => item.aid && ['', 'active'].includes(item.status))
|
|
1496
|
+
.map((item) => item.aid)
|
|
1497
|
+
.sort();
|
|
1498
|
+
if (!activeMembers.includes(this._aid) || activeMembers.length === 0)
|
|
1499
|
+
return false;
|
|
1500
|
+
if (activeMembers.join('\n') !== expectedMembers.join('\n')) {
|
|
1501
|
+
const missing = activeMembers.filter((aid) => !expectedMembers.includes(aid));
|
|
1502
|
+
const extra = expectedMembers.filter((aid) => !activeMembers.includes(aid));
|
|
1503
|
+
_clientLog('info', '群 %s committed membership gap: epoch=%s missing=%s extra=%s', groupId, committedEpoch, JSON.stringify(missing), JSON.stringify(extra));
|
|
1504
|
+
return true;
|
|
1505
|
+
}
|
|
1506
|
+
return false;
|
|
1507
|
+
}
|
|
1508
|
+
catch (exc) {
|
|
1509
|
+
_clientLog('debug', '查询当前成员失败,无法判断 committed membership gap: group=%s err=%s', groupId, formatCaughtError(exc));
|
|
1510
|
+
return false;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1299
1513
|
// ── 客户端签名 ────────────────────────────────────────────
|
|
1300
1514
|
/**
|
|
1301
1515
|
* 为关键操作附加客户端 ECDSA 签名(client_signature 字段)。
|
|
@@ -1795,6 +2009,11 @@ export class AUNClient {
|
|
|
1795
2009
|
// 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
|
|
1796
2010
|
if (et === 'group.message_created')
|
|
1797
2011
|
continue;
|
|
2012
|
+
// 验签:有 client_signature 就验(与实时事件路径对齐)
|
|
2013
|
+
const cs = evt.client_signature;
|
|
2014
|
+
if (cs && typeof cs === 'object') {
|
|
2015
|
+
evt._verified = await this._verifyEventSignatureAsync(evt, cs);
|
|
2016
|
+
}
|
|
1798
2017
|
// group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
|
|
1799
2018
|
await this._dispatcher.publish('group.changed', evt);
|
|
1800
2019
|
}
|
|
@@ -1809,37 +2028,6 @@ export class AUNClient {
|
|
|
1809
2028
|
this._gapFillDone.delete(dedupKey);
|
|
1810
2029
|
}
|
|
1811
2030
|
}
|
|
1812
|
-
/**
|
|
1813
|
-
* 上线/重连后一次性同步所有已加入群:
|
|
1814
|
-
* 1. 有 epoch key 的群 → 补消息 + 补事件
|
|
1815
|
-
* 2. 无 epoch key 的群 → 仅补事件(事件不加密,等推送触发密钥恢复)
|
|
1816
|
-
*/
|
|
1817
|
-
async _syncAllGroupsOnce() {
|
|
1818
|
-
try {
|
|
1819
|
-
const result = await this.call('group.list_my', {});
|
|
1820
|
-
if (!isJsonObject(result))
|
|
1821
|
-
return;
|
|
1822
|
-
const items = result.items;
|
|
1823
|
-
if (!Array.isArray(items))
|
|
1824
|
-
return;
|
|
1825
|
-
for (const g of items) {
|
|
1826
|
-
if (isJsonObject(g)) {
|
|
1827
|
-
const gid = String(g.group_id ?? '');
|
|
1828
|
-
if (gid) {
|
|
1829
|
-
// 有 epoch key → 补消息
|
|
1830
|
-
if (this._groupE2ee.hasSecret(gid)) {
|
|
1831
|
-
await this._fillGroupGap(gid);
|
|
1832
|
-
}
|
|
1833
|
-
// 所有群都补事件(事件不加密)
|
|
1834
|
-
await this._fillGroupEventGap(gid);
|
|
1835
|
-
}
|
|
1836
|
-
}
|
|
1837
|
-
}
|
|
1838
|
-
}
|
|
1839
|
-
catch (exc) {
|
|
1840
|
-
_clientLog('debug', '批量同步群失败: %s', formatCaughtError(exc));
|
|
1841
|
-
}
|
|
1842
|
-
}
|
|
1843
2031
|
/**
|
|
1844
2032
|
* 处理群组变更事件:透传给用户,并在成员离开/被踢时自动触发 epoch 轮换。
|
|
1845
2033
|
* 按协议,轮换由剩余在线 admin/owner 负责。
|
|
@@ -2015,15 +2203,38 @@ export class AUNClient {
|
|
|
2015
2203
|
}
|
|
2016
2204
|
}
|
|
2017
2205
|
}
|
|
2206
|
+
// 成员加入:按 action 区分策略
|
|
2207
|
+
// - member_added / join_approved(私密群/审批群):admin 必然在线,立即轮换
|
|
2208
|
+
// - joined / invite_code_used(开放群/邀请码群):新成员先恢复 committed_epoch,延迟轮换
|
|
2018
2209
|
if (['member_added', 'joined', 'join_approved', 'invite_code_used'].includes(String(d.action ?? ''))) {
|
|
2019
2210
|
if (groupId) {
|
|
2020
2211
|
{
|
|
2212
|
+
const action = String(d.action ?? '');
|
|
2021
2213
|
const expectedEpoch = this._membershipRotationExpectedEpoch(d);
|
|
2022
|
-
|
|
2023
|
-
|
|
2214
|
+
const joinedAids = this._joinedMemberAidsFromPayload(d);
|
|
2215
|
+
const isSelfJoining = joinedAids.includes(this._aid ?? '') && (action === 'joined' || action === 'invite_code_used');
|
|
2216
|
+
_clientLog('warn', 'DEBUG: group.changed action=%s groupId=%s joinedAids=%s myAid=%s isSelfJoining=%s expectedEpoch=%s', action, groupId, JSON.stringify(joinedAids), this._aid, String(isSelfJoining), String(expectedEpoch));
|
|
2217
|
+
if (isSelfJoining || (action === 'joined' || action === 'invite_code_used')) {
|
|
2218
|
+
// open/invite_code 群:所有在线成员都参与延迟轮换
|
|
2219
|
+
// 新成员自己延迟更长,优先让其他在线成员先轮换
|
|
2220
|
+
const triggerId = this._membershipRotationTriggerId(groupId, d);
|
|
2221
|
+
if (!isSelfJoining) {
|
|
2222
|
+
this._maybeBackfillKeyToJoinedMember(groupId, d, triggerId).catch((exc) => this._logE2eeError('backfill_key', groupId, '', exc));
|
|
2223
|
+
}
|
|
2224
|
+
if (expectedEpoch !== null) {
|
|
2225
|
+
const delay = isSelfJoining ? AUNClient._SELF_JOIN_ROTATION_DELAY_MS : undefined;
|
|
2226
|
+
this._delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, true, delay).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
|
|
2227
|
+
}
|
|
2024
2228
|
}
|
|
2025
2229
|
else {
|
|
2026
|
-
|
|
2230
|
+
// member_added / join_approved:立即轮换
|
|
2231
|
+
if (expectedEpoch === null) {
|
|
2232
|
+
const triggerId = this._membershipRotationTriggerId(groupId, d);
|
|
2233
|
+
this._maybeBackfillKeyToJoinedMember(groupId, d, triggerId).catch((exc) => this._logE2eeError('backfill_key', groupId, '', exc));
|
|
2234
|
+
}
|
|
2235
|
+
else {
|
|
2236
|
+
this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, d), expectedEpoch).catch((exc) => this._logE2eeError('rotate_epoch', groupId, '', exc));
|
|
2237
|
+
}
|
|
2027
2238
|
}
|
|
2028
2239
|
}
|
|
2029
2240
|
}
|
|
@@ -2040,11 +2251,94 @@ export class AUNClient {
|
|
|
2040
2251
|
await this._dispatcher.publish('group.changed', data);
|
|
2041
2252
|
}
|
|
2042
2253
|
}
|
|
2254
|
+
/**
|
|
2255
|
+
* 处理 event/group.state_committed:验证 state_hash 链并更新本地存储。
|
|
2256
|
+
* 当链断裂时回源 group.get_state,并对回源结果做本地 hash 重算验证。
|
|
2257
|
+
*/
|
|
2258
|
+
async _onGroupStateCommitted(data) {
|
|
2259
|
+
if (!isJsonObject(data))
|
|
2260
|
+
return;
|
|
2261
|
+
const d = data;
|
|
2262
|
+
const groupId = String(d.group_id ?? '').trim();
|
|
2263
|
+
if (!groupId)
|
|
2264
|
+
return;
|
|
2265
|
+
// 提交者签名验证(兼容旧版:无签名时继续)
|
|
2266
|
+
const cs = d.client_signature;
|
|
2267
|
+
if (cs && isJsonObject(cs)) {
|
|
2268
|
+
const verified = await this._verifyEventSignatureAsync(d, cs);
|
|
2269
|
+
if (verified === false) {
|
|
2270
|
+
_clientLog('warn', 'state_committed 提交者签名验证失败 group=%s', groupId);
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
d._verified = verified;
|
|
2274
|
+
}
|
|
2275
|
+
const stateVersion = Number(d.state_version ?? 0);
|
|
2276
|
+
const stateHash = String(d.state_hash ?? '').trim();
|
|
2277
|
+
const prevStateHash = String(d.prev_state_hash ?? '').trim();
|
|
2278
|
+
const keyEpoch = Number(d.key_epoch ?? 0);
|
|
2279
|
+
const membershipSnapshot = String(d.membership_snapshot ?? '').trim();
|
|
2280
|
+
const policySnapshot = String(d.policy_snapshot ?? '').trim();
|
|
2281
|
+
// 1. 验证 prev_state_hash 连续性
|
|
2282
|
+
const loadFn = this._keystore.loadGroupState;
|
|
2283
|
+
const localState = loadFn ? loadFn.call(this._keystore, groupId) : null;
|
|
2284
|
+
if (localState && localState.state_hash && localState.state_hash !== prevStateHash) {
|
|
2285
|
+
_clientLog('warn', 'state_hash 链不连续 group=%s local_sv=%d event_sv=%d', groupId, localState.state_version, stateVersion);
|
|
2286
|
+
// 回源同步
|
|
2287
|
+
try {
|
|
2288
|
+
const serverState = await this._transport.call('group.get_state', { group_id: groupId });
|
|
2289
|
+
if (serverState && isJsonObject(serverState) && 'state_version' in serverState) {
|
|
2290
|
+
const sv = Number(serverState.state_version ?? 0);
|
|
2291
|
+
const sHash = String(serverState.state_hash ?? '');
|
|
2292
|
+
const sEpoch = Number(serverState.key_epoch ?? 0);
|
|
2293
|
+
const sMembersJson = String(serverState.membership_snapshot ?? '');
|
|
2294
|
+
const sPolicyJson = String(serverState.policy_snapshot ?? '');
|
|
2295
|
+
const sPrev = String(serverState.prev_state_hash ?? '');
|
|
2296
|
+
// 回源也做 hash 验证
|
|
2297
|
+
if (sMembersJson && sHash) {
|
|
2298
|
+
const sMembers = sMembersJson ? JSON.parse(sMembersJson) : [];
|
|
2299
|
+
const sPolicy = sPolicyJson ? JSON.parse(sPolicyJson) : {};
|
|
2300
|
+
const computed = computeStateHash({
|
|
2301
|
+
groupId, stateVersion: sv, keyEpoch: sEpoch,
|
|
2302
|
+
members: sMembers, policy: sPolicy, prevStateHash: sPrev,
|
|
2303
|
+
});
|
|
2304
|
+
if (computed !== sHash) {
|
|
2305
|
+
_clientLog('warn', '回源 state_hash 验证失败 group=%s sv=%d expected=%s got=%s', groupId, sv, sHash, computed);
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
const saveFn = this._keystore.saveGroupState;
|
|
2310
|
+
if (saveFn) {
|
|
2311
|
+
saveFn.call(this._keystore, groupId, sv, sHash, sEpoch, sMembersJson || membershipSnapshot, sPolicyJson || policySnapshot);
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
catch (exc) {
|
|
2316
|
+
_clientLog('warn', 'state 回源失败 group=%s: %s', groupId, formatCaughtError(exc));
|
|
2317
|
+
}
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
// 2. 本地重算验证
|
|
2321
|
+
const members = membershipSnapshot ? JSON.parse(membershipSnapshot) : [];
|
|
2322
|
+
const policy = policySnapshot ? JSON.parse(policySnapshot) : {};
|
|
2323
|
+
const computed = computeStateHash({
|
|
2324
|
+
groupId, stateVersion, keyEpoch,
|
|
2325
|
+
members, policy, prevStateHash,
|
|
2326
|
+
});
|
|
2327
|
+
if (computed !== stateHash) {
|
|
2328
|
+
_clientLog('warn', 'state_hash 重算不匹配 group=%s sv=%d expected=%s got=%s', groupId, stateVersion, stateHash, computed);
|
|
2329
|
+
return;
|
|
2330
|
+
}
|
|
2331
|
+
// 3. 更新本地存储
|
|
2332
|
+
const saveFn = this._keystore.saveGroupState;
|
|
2333
|
+
if (saveFn) {
|
|
2334
|
+
saveFn.call(this._keystore, groupId, stateVersion, stateHash, keyEpoch, membershipSnapshot, policySnapshot);
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2043
2337
|
/**
|
|
2044
2338
|
* 成员退出/被踢后,判断本地是否为 leader admin 并发起 epoch 轮换。
|
|
2045
2339
|
* 避免所有剩余 admin 同时触发 `_rotateGroupEpoch` 造成 CAS 风暴。
|
|
2046
2340
|
*/
|
|
2047
|
-
async _maybeLeadRotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null) {
|
|
2341
|
+
async _maybeLeadRotateGroupEpoch(groupId, triggerId = '', expectedEpoch = null, allowMember = false) {
|
|
2048
2342
|
const myAid = this._aid;
|
|
2049
2343
|
if (!myAid || this._closing || this._state !== 'connected')
|
|
2050
2344
|
return;
|
|
@@ -2073,24 +2367,46 @@ export class AUNClient {
|
|
|
2073
2367
|
if (!Array.isArray(rawList))
|
|
2074
2368
|
return;
|
|
2075
2369
|
const admins = [];
|
|
2370
|
+
const members = [];
|
|
2076
2371
|
for (const m of rawList) {
|
|
2077
2372
|
if (!isJsonObject(m))
|
|
2078
2373
|
continue;
|
|
2079
2374
|
const role = String(m.role ?? '');
|
|
2080
2375
|
const aid = String(m.aid ?? '');
|
|
2081
|
-
if (aid
|
|
2376
|
+
if (!aid)
|
|
2377
|
+
continue;
|
|
2378
|
+
if (role === 'admin' || role === 'owner') {
|
|
2082
2379
|
admins.push(aid);
|
|
2380
|
+
}
|
|
2381
|
+
else if (allowMember && role === 'member') {
|
|
2382
|
+
members.push(aid);
|
|
2383
|
+
}
|
|
2083
2384
|
}
|
|
2084
|
-
|
|
2385
|
+
// 候选列表:admin/owner 排序在前,member 排序在后
|
|
2386
|
+
let candidates = [...admins.sort(), ...members.sort()];
|
|
2387
|
+
if (candidates.length === 0)
|
|
2085
2388
|
return;
|
|
2086
|
-
|
|
2087
|
-
|
|
2389
|
+
// 没有当前 epoch key 的成员不参与 leader 选举。
|
|
2390
|
+
// open/invite_code 群排除后为空时保留自己兜底(从服务端取 prev chain)。
|
|
2391
|
+
if (expectedEpoch !== null && expectedEpoch > 0) {
|
|
2392
|
+
const localSecret = this._groupE2ee.loadSecret(groupId, expectedEpoch);
|
|
2393
|
+
if (!localSecret) {
|
|
2394
|
+
const filtered = candidates.filter(c => c !== myAid);
|
|
2395
|
+
if (filtered.length > 0) {
|
|
2396
|
+
candidates = filtered;
|
|
2397
|
+
}
|
|
2398
|
+
else if (!allowMember) {
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
const leader = candidates[0];
|
|
2088
2404
|
if (leader === myAid) {
|
|
2089
2405
|
// 我是 leader,直接发起
|
|
2090
2406
|
await this._rotateGroupEpoch(groupId, triggerId, expectedEpoch);
|
|
2091
2407
|
return;
|
|
2092
2408
|
}
|
|
2093
|
-
if (!
|
|
2409
|
+
if (!candidates.includes(myAid))
|
|
2094
2410
|
return;
|
|
2095
2411
|
// 非 leader:随机 jitter(2~6s)后查询服务端 epoch 是否已被 leader 推进
|
|
2096
2412
|
const jitterMs = 2000 + Math.floor(Math.random() * 4000);
|
|
@@ -2280,6 +2596,11 @@ export class AUNClient {
|
|
|
2280
2596
|
result = this._groupE2ee.handleIncoming(actualPayload);
|
|
2281
2597
|
if (result === 'distribution') {
|
|
2282
2598
|
await this._discardGroupDistributionIfStale(actualPayload);
|
|
2599
|
+
// 收到 epoch key 说明该群有活动,触发惰性同步建立 seq 基线
|
|
2600
|
+
const distGroupId = actualPayload.group_id;
|
|
2601
|
+
if (distGroupId && !this._groupSynced.has(distGroupId)) {
|
|
2602
|
+
this._lazySyncGroup(distGroupId).catch(() => { });
|
|
2603
|
+
}
|
|
2283
2604
|
}
|
|
2284
2605
|
// S14: 非控制面消息且 handleIncoming 不识别 → 不拦截
|
|
2285
2606
|
if (!isControlPlane && result === null)
|
|
@@ -2289,7 +2610,9 @@ export class AUNClient {
|
|
|
2289
2610
|
const groupId = String(actualPayload.group_id ?? '');
|
|
2290
2611
|
const requester = String(actualPayload.requester_aid ?? '');
|
|
2291
2612
|
let members = this._groupE2ee.getMemberAids(groupId);
|
|
2292
|
-
//
|
|
2613
|
+
// 请求者不在本地成员列表时,回源查询服务端最新成员列表,
|
|
2614
|
+
// 仅用于传递给 handleKeyRequestMsg 做鉴权,不更新本地密钥存储
|
|
2615
|
+
// (历史 epoch 的成员隔离由 handleKeyRequest 内部负责)。
|
|
2293
2616
|
if (requester && !members.includes(requester)) {
|
|
2294
2617
|
try {
|
|
2295
2618
|
const membersResult = await this.call('group.get_members', { group_id: groupId });
|
|
@@ -2297,15 +2620,6 @@ export class AUNClient {
|
|
|
2297
2620
|
? membersResult.members
|
|
2298
2621
|
: [];
|
|
2299
2622
|
members = memberList.map((m) => String(m.aid));
|
|
2300
|
-
// 更新本地当前 epoch 的 member_aids/commitment
|
|
2301
|
-
if (members.includes(requester)) {
|
|
2302
|
-
const secretData = this._groupE2ee.loadSecret(groupId);
|
|
2303
|
-
if (secretData && this._aid) {
|
|
2304
|
-
const epoch = secretData.epoch;
|
|
2305
|
-
const commitment = computeMembershipCommitment(members, epoch, groupId, secretData.secret);
|
|
2306
|
-
storeGroupSecret(this._keystore, this._aid, groupId, epoch, secretData.secret, commitment, members);
|
|
2307
|
-
}
|
|
2308
|
-
}
|
|
2309
2623
|
}
|
|
2310
2624
|
catch (exc) {
|
|
2311
2625
|
_clientLog('warn', '群组 %s 成员列表回源失败: %s', groupId, formatCaughtError(exc));
|
|
@@ -2416,11 +2730,17 @@ export class AUNClient {
|
|
|
2416
2730
|
async _fetchPeerPrekeys(peerAid) {
|
|
2417
2731
|
const cachedList = this._peerPrekeysCache.get(peerAid);
|
|
2418
2732
|
if (cachedList && Date.now() / 1000 < cachedList.expireAt) {
|
|
2419
|
-
|
|
2733
|
+
const normalized = normalizePeerPrekeys(cachedList.items);
|
|
2734
|
+
if (normalized.length > 0) {
|
|
2735
|
+
return normalized.map((item) => ({ ...item }));
|
|
2736
|
+
}
|
|
2420
2737
|
}
|
|
2421
2738
|
const cached = this._e2ee.getCachedPrekey(peerAid);
|
|
2422
|
-
if (cached !== null)
|
|
2423
|
-
|
|
2739
|
+
if (cached !== null) {
|
|
2740
|
+
const normalized = normalizePeerPrekeys([cached]);
|
|
2741
|
+
if (normalized.length > 0)
|
|
2742
|
+
return normalized.map((item) => ({ ...item }));
|
|
2743
|
+
}
|
|
2424
2744
|
try {
|
|
2425
2745
|
const result = await this._transport.call('message.e2ee.get_prekey', { aid: peerAid });
|
|
2426
2746
|
if (!isJsonObject(result)) {
|
|
@@ -2431,17 +2751,11 @@ export class AUNClient {
|
|
|
2431
2751
|
}
|
|
2432
2752
|
const devicePrekeys = Array.isArray(result.device_prekeys) ? result.device_prekeys : null;
|
|
2433
2753
|
if (devicePrekeys) {
|
|
2434
|
-
const normalized =
|
|
2435
|
-
for (const item of devicePrekeys) {
|
|
2436
|
-
if (!isPeerPrekeyMaterial(item)) {
|
|
2437
|
-
continue;
|
|
2438
|
-
}
|
|
2439
|
-
normalized.push({ ...item });
|
|
2440
|
-
}
|
|
2754
|
+
const normalized = normalizePeerPrekeys(devicePrekeys);
|
|
2441
2755
|
if (normalized.length > 0) {
|
|
2442
2756
|
this._peerPrekeysCache.set(peerAid, {
|
|
2443
2757
|
items: normalized.map((item) => ({ ...item })),
|
|
2444
|
-
expireAt: Date.now() / 1000 +
|
|
2758
|
+
expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
|
|
2445
2759
|
});
|
|
2446
2760
|
this._e2ee.cachePrekey(peerAid, normalized[0]);
|
|
2447
2761
|
return normalized;
|
|
@@ -2452,12 +2766,15 @@ export class AUNClient {
|
|
|
2452
2766
|
}
|
|
2453
2767
|
const prekey = result.prekey;
|
|
2454
2768
|
if (prekey) {
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2769
|
+
const normalized = normalizePeerPrekeys([prekey]);
|
|
2770
|
+
if (normalized.length > 0) {
|
|
2771
|
+
this._peerPrekeysCache.set(peerAid, {
|
|
2772
|
+
items: normalized.map((item) => ({ ...item })),
|
|
2773
|
+
expireAt: Date.now() / 1000 + PEER_PREKEYS_CACHE_TTL,
|
|
2774
|
+
});
|
|
2775
|
+
this._e2ee.cachePrekey(peerAid, normalized[0]);
|
|
2776
|
+
return normalized.map((item) => ({ ...item }));
|
|
2777
|
+
}
|
|
2461
2778
|
}
|
|
2462
2779
|
if (result.found) {
|
|
2463
2780
|
throw new ValidationError(`invalid prekey response for ${peerAid}`);
|
|
@@ -2475,7 +2792,10 @@ export class AUNClient {
|
|
|
2475
2792
|
async _fetchPeerPrekey(peerAid) {
|
|
2476
2793
|
const cachedList = this._peerPrekeysCache.get(peerAid);
|
|
2477
2794
|
if (cachedList && Date.now() / 1000 < cachedList.expireAt && cachedList.items.length > 0) {
|
|
2478
|
-
|
|
2795
|
+
const normalized = normalizePeerPrekeys(cachedList.items);
|
|
2796
|
+
if (normalized.length > 0) {
|
|
2797
|
+
return { ...normalized[0] };
|
|
2798
|
+
}
|
|
2479
2799
|
}
|
|
2480
2800
|
const prekeys = await this._fetchPeerPrekeys(peerAid);
|
|
2481
2801
|
if (prekeys.length === 0) {
|
|
@@ -2483,6 +2803,25 @@ export class AUNClient {
|
|
|
2483
2803
|
}
|
|
2484
2804
|
return { ...prekeys[0] };
|
|
2485
2805
|
}
|
|
2806
|
+
/** 清除对端 prekey 的双层缓存(_peerPrekeysCache + e2ee 内部缓存) */
|
|
2807
|
+
_invalidatePeerPrekeyCache(peerAid) {
|
|
2808
|
+
this._peerPrekeysCache.delete(peerAid);
|
|
2809
|
+
this._e2ee.invalidatePrekeyCache(peerAid);
|
|
2810
|
+
}
|
|
2811
|
+
/** 清除对端证书缓存(精确匹配 aid 或 aid# 前缀的所有条目) */
|
|
2812
|
+
_clearPeerCertCache(peerAid) {
|
|
2813
|
+
for (const cacheKey of this._certCache.keys()) {
|
|
2814
|
+
if (cacheKey === peerAid || cacheKey.startsWith(`${peerAid}#`)) {
|
|
2815
|
+
this._certCache.delete(cacheKey);
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
/** 清除对端所有缓存后重新拉取 prekey(用于指纹不匹配时的强制刷新) */
|
|
2820
|
+
async _refreshPeerPrekeys(peerAid) {
|
|
2821
|
+
this._invalidatePeerPrekeyCache(peerAid);
|
|
2822
|
+
this._clearPeerCertCache(peerAid);
|
|
2823
|
+
return await this._fetchPeerPrekeys(peerAid);
|
|
2824
|
+
}
|
|
2486
2825
|
/** 生成 prekey 并上传到服务端 */
|
|
2487
2826
|
async _uploadPrekey() {
|
|
2488
2827
|
const prekeyMaterial = this._e2ee.generatePrekey();
|
|
@@ -2546,7 +2885,11 @@ export class AUNClient {
|
|
|
2546
2885
|
* 零信任:不直接信任 keystore 中可能由恶意服务端注入的证书。
|
|
2547
2886
|
*/
|
|
2548
2887
|
_getVerifiedPeerCert(aid, certFingerprint) {
|
|
2549
|
-
|
|
2888
|
+
let cached = this._certCache.get(AUNClient._certCacheKey(aid, certFingerprint));
|
|
2889
|
+
// 带 fingerprint 查不到时,降级用 aid 再查一次
|
|
2890
|
+
if (!cached && certFingerprint) {
|
|
2891
|
+
cached = this._certCache.get(AUNClient._certCacheKey(aid, undefined));
|
|
2892
|
+
}
|
|
2550
2893
|
const now = Date.now() / 1000;
|
|
2551
2894
|
if (cached && now < cached.validatedAt + PEER_CERT_CACHE_TTL * 2) {
|
|
2552
2895
|
return cached.certPem;
|
|
@@ -2687,7 +3030,254 @@ export class AUNClient {
|
|
|
2687
3030
|
this._groupEpochRecoveryInflight.set(key, promise);
|
|
2688
3031
|
return promise;
|
|
2689
3032
|
}
|
|
3033
|
+
static _extractGroupJoinMode(payload) {
|
|
3034
|
+
if (!isJsonObject(payload))
|
|
3035
|
+
return '';
|
|
3036
|
+
for (const key of ['join_mode', 'mode']) {
|
|
3037
|
+
const v = String(payload[key] ?? '').trim().toLowerCase();
|
|
3038
|
+
if (v)
|
|
3039
|
+
return v;
|
|
3040
|
+
}
|
|
3041
|
+
for (const key of ['join_requirements', 'join']) {
|
|
3042
|
+
const nested = payload[key];
|
|
3043
|
+
if (isJsonObject(nested)) {
|
|
3044
|
+
for (const nk of ['mode', 'join_mode']) {
|
|
3045
|
+
const v = String(nested[nk] ?? '').trim().toLowerCase();
|
|
3046
|
+
if (v)
|
|
3047
|
+
return v;
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
if (isJsonObject(payload.group)) {
|
|
3052
|
+
const v = AUNClient._extractGroupJoinMode(payload.group);
|
|
3053
|
+
if (v)
|
|
3054
|
+
return v;
|
|
3055
|
+
}
|
|
3056
|
+
const settings = payload.settings;
|
|
3057
|
+
if (isJsonObject(settings)) {
|
|
3058
|
+
for (const key of ['join.mode', 'join_mode', 'mode']) {
|
|
3059
|
+
const v = String(settings[key] ?? '').trim().toLowerCase();
|
|
3060
|
+
if (v)
|
|
3061
|
+
return v;
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
if (Array.isArray(settings)) {
|
|
3065
|
+
for (const item of settings) {
|
|
3066
|
+
if (!isJsonObject(item))
|
|
3067
|
+
continue;
|
|
3068
|
+
const k = String(item.key ?? item.name ?? '').trim().toLowerCase();
|
|
3069
|
+
if (k === 'join.mode' || k === 'join_mode' || k === 'mode') {
|
|
3070
|
+
const v = String(item.value ?? '').trim().toLowerCase();
|
|
3071
|
+
if (v)
|
|
3072
|
+
return v;
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
return '';
|
|
3077
|
+
}
|
|
3078
|
+
static _joinModeAllowsMemberEpochRotation(mode) {
|
|
3079
|
+
const m = mode.trim().toLowerCase();
|
|
3080
|
+
return m === 'open' || m === 'invite_only' || m === 'invite_code';
|
|
3081
|
+
}
|
|
3082
|
+
async _groupAllowsMemberEpochRotation(groupId) {
|
|
3083
|
+
try {
|
|
3084
|
+
const resp = await this.call('group.get_join_requirements', { group_id: groupId });
|
|
3085
|
+
const mode = AUNClient._extractGroupJoinMode(resp);
|
|
3086
|
+
if (mode)
|
|
3087
|
+
return AUNClient._joinModeAllowsMemberEpochRotation(mode);
|
|
3088
|
+
}
|
|
3089
|
+
catch { /* best effort */ }
|
|
3090
|
+
try {
|
|
3091
|
+
const resp = await this.call('group.get_settings', { group_id: groupId, keys: ['join.mode'] });
|
|
3092
|
+
const mode = AUNClient._extractGroupJoinMode(resp);
|
|
3093
|
+
if (mode)
|
|
3094
|
+
return AUNClient._joinModeAllowsMemberEpochRotation(mode);
|
|
3095
|
+
}
|
|
3096
|
+
catch { /* best effort */ }
|
|
3097
|
+
try {
|
|
3098
|
+
const resp = await this.call('group.get', { group_id: groupId });
|
|
3099
|
+
const mode = AUNClient._extractGroupJoinMode(resp);
|
|
3100
|
+
if (mode)
|
|
3101
|
+
return AUNClient._joinModeAllowsMemberEpochRotation(mode);
|
|
3102
|
+
}
|
|
3103
|
+
catch { /* best effort */ }
|
|
3104
|
+
return false;
|
|
3105
|
+
}
|
|
3106
|
+
/** 尝试从服务端拉取 ECIES 加密的 epoch key 并解密存入 keystore */
|
|
3107
|
+
async _tryRecoverEpochKeyFromServer(groupId, epoch) {
|
|
3108
|
+
try {
|
|
3109
|
+
const params = { group_id: groupId };
|
|
3110
|
+
if (epoch > 0)
|
|
3111
|
+
params.epoch = epoch;
|
|
3112
|
+
const result = await this.call('group.e2ee.get_epoch_key', params);
|
|
3113
|
+
if (!isJsonObject(result))
|
|
3114
|
+
return false;
|
|
3115
|
+
const encryptedB64 = result.encrypted_key;
|
|
3116
|
+
if (!encryptedB64 || typeof encryptedB64 !== 'string')
|
|
3117
|
+
return false;
|
|
3118
|
+
const serverEpoch = Number(result.epoch ?? epoch);
|
|
3119
|
+
const encryptedBytes = Buffer.from(encryptedB64, 'base64');
|
|
3120
|
+
// 用自己的 AID 私钥 ECIES 解密
|
|
3121
|
+
const myAid = this._aid || '';
|
|
3122
|
+
const keyPair = this._keystore.loadKeyPair(myAid);
|
|
3123
|
+
if (!keyPair?.private_key_pem) {
|
|
3124
|
+
_clientLog('warn', '无法加载 AID 私钥用于 ECIES 解密: aid=%s', myAid);
|
|
3125
|
+
return false;
|
|
3126
|
+
}
|
|
3127
|
+
const { eciesDecrypt } = await import('./e2ee-group.js');
|
|
3128
|
+
const groupSecret = eciesDecrypt(keyPair.private_key_pem, encryptedBytes);
|
|
3129
|
+
if (!groupSecret || groupSecret.length !== 32) {
|
|
3130
|
+
_clientLog('warn', '服务端 epoch key ECIES 解密结果长度异常: group=%s epoch=%d len=%d', groupId, serverEpoch, groupSecret?.length ?? 0);
|
|
3131
|
+
return false;
|
|
3132
|
+
}
|
|
3133
|
+
// 获取成员列表和 committed_rotation 用于 commitment / epoch_chain 验证
|
|
3134
|
+
let memberAids = [];
|
|
3135
|
+
let committedRotation = null;
|
|
3136
|
+
let epochChain = '';
|
|
3137
|
+
try {
|
|
3138
|
+
const epochInfo = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
3139
|
+
if (isJsonObject(epochInfo)) {
|
|
3140
|
+
if (Array.isArray(epochInfo.members)) {
|
|
3141
|
+
memberAids = epochInfo.members
|
|
3142
|
+
.map((m) => {
|
|
3143
|
+
if (typeof m === 'string')
|
|
3144
|
+
return m;
|
|
3145
|
+
if (isJsonObject(m) && typeof m.aid === 'string')
|
|
3146
|
+
return m.aid;
|
|
3147
|
+
return '';
|
|
3148
|
+
})
|
|
3149
|
+
.filter((s) => s.length > 0);
|
|
3150
|
+
}
|
|
3151
|
+
if (isJsonObject(epochInfo.committed_rotation)) {
|
|
3152
|
+
committedRotation = epochInfo.committed_rotation;
|
|
3153
|
+
const rawChain = String(committedRotation.epoch_chain ?? '').trim();
|
|
3154
|
+
if (rawChain)
|
|
3155
|
+
epochChain = rawChain;
|
|
3156
|
+
// 如果有 expected_members,用它覆盖 memberAids
|
|
3157
|
+
if (Array.isArray(committedRotation.expected_members) && committedRotation.expected_members.length > 0) {
|
|
3158
|
+
memberAids = committedRotation.expected_members
|
|
3159
|
+
.map(item => String(item ?? '').trim())
|
|
3160
|
+
.filter(s => s.length > 0);
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
catch { /* best effort */ }
|
|
3166
|
+
if (memberAids.length === 0) {
|
|
3167
|
+
_clientLog('warn', '服务端 epoch key 恢复缺少成员快照: group=%s epoch=%d', groupId, serverEpoch);
|
|
3168
|
+
return false;
|
|
3169
|
+
}
|
|
3170
|
+
const commitment = computeMembershipCommitment(memberAids, serverEpoch, groupId, groupSecret);
|
|
3171
|
+
// committed_rotation 存在时验证 commitment 和 epoch_chain
|
|
3172
|
+
let epochChainUnverified = null;
|
|
3173
|
+
let epochChainUnverifiedReason = null;
|
|
3174
|
+
if (committedRotation) {
|
|
3175
|
+
const committedEpoch = Number(committedRotation.target_epoch ?? serverEpoch);
|
|
3176
|
+
const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
|
|
3177
|
+
if (committedEpoch === serverEpoch && committedCommitment && committedCommitment !== commitment) {
|
|
3178
|
+
_clientLog('warn', '服务端 epoch key 恢复 commitment 不匹配: group=%s epoch=%d', groupId, serverEpoch);
|
|
3179
|
+
return false;
|
|
3180
|
+
}
|
|
3181
|
+
if (epochChain && committedEpoch === serverEpoch) {
|
|
3182
|
+
let rotatorAid = '';
|
|
3183
|
+
for (const key of ['rotated_by', 'lease_owner', 'committed_by']) {
|
|
3184
|
+
const v = String(committedRotation[key] ?? '').trim();
|
|
3185
|
+
if (v) {
|
|
3186
|
+
rotatorAid = v;
|
|
3187
|
+
break;
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
const prevData = this._groupE2ee.loadSecret(groupId, serverEpoch - 1);
|
|
3191
|
+
const prevChain = String(prevData?.epoch_chain ?? '').trim();
|
|
3192
|
+
if (prevChain && rotatorAid) {
|
|
3193
|
+
if (!verifyEpochChain(epochChain, prevChain, serverEpoch, commitment, rotatorAid)) {
|
|
3194
|
+
_clientLog('warn', '服务端 epoch key 恢复 epoch_chain 验证失败: group=%s epoch=%d rotator=%s', groupId, serverEpoch, rotatorAid);
|
|
3195
|
+
return false;
|
|
3196
|
+
}
|
|
3197
|
+
epochChainUnverified = false;
|
|
3198
|
+
}
|
|
3199
|
+
else {
|
|
3200
|
+
epochChainUnverified = true;
|
|
3201
|
+
epochChainUnverifiedReason = prevChain ? 'missing_rotator_aid' : 'missing_prev_chain';
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
const stored = storeGroupSecretEpoch(this._keystore, myAid, groupId, serverEpoch, groupSecret, commitment, memberAids, epochChain || undefined, '', epochChainUnverified, epochChainUnverifiedReason);
|
|
3206
|
+
if (!stored) {
|
|
3207
|
+
_clientLog('warn', '服务端 epoch key 恢复存储失败: group=%s epoch=%d', groupId, serverEpoch);
|
|
3208
|
+
return false;
|
|
3209
|
+
}
|
|
3210
|
+
_clientLog('info', '从服务端恢复 epoch key 成功: group=%s epoch=%d', groupId, serverEpoch);
|
|
3211
|
+
return true;
|
|
3212
|
+
}
|
|
3213
|
+
catch (exc) {
|
|
3214
|
+
_clientLog('debug', '从服务端恢复 epoch key 失败: group=%s epoch=%d err=%s', groupId, epoch, formatCaughtError(exc));
|
|
3215
|
+
return false;
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
/** 为每个成员用其 AID 证书公钥 ECIES 加密 group_secret,返回 {aid: base64_ciphertext} */
|
|
3219
|
+
async _buildEpochEncryptedKeys(info, memberAids, targetEpoch, groupId) {
|
|
3220
|
+
try {
|
|
3221
|
+
const { eciesEncrypt } = await import('./e2ee-group.js');
|
|
3222
|
+
// 从 distribution payload 中提取 group_secret
|
|
3223
|
+
let groupSecretBytes = null;
|
|
3224
|
+
const distributions = Array.isArray(info.distributions) ? info.distributions : [];
|
|
3225
|
+
for (const dist of distributions) {
|
|
3226
|
+
if (isJsonObject(dist) && isJsonObject(dist.payload)) {
|
|
3227
|
+
const gsB64 = dist.payload.group_secret;
|
|
3228
|
+
if (typeof gsB64 === 'string' && gsB64.length > 0) {
|
|
3229
|
+
groupSecretBytes = Buffer.from(gsB64, 'base64');
|
|
3230
|
+
break;
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
if (!groupSecretBytes) {
|
|
3235
|
+
// fallback: 从本地 keystore 加载
|
|
3236
|
+
const loaded = this._groupE2ee.loadSecret(groupId, targetEpoch);
|
|
3237
|
+
if (loaded?.secret) {
|
|
3238
|
+
groupSecretBytes = loaded.secret;
|
|
3239
|
+
}
|
|
3240
|
+
else {
|
|
3241
|
+
_clientLog('debug', '无法获取 group_secret 用于 ECIES 加密: group=%s epoch=%d', groupId, targetEpoch);
|
|
3242
|
+
return {};
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
const encryptedKeys = {};
|
|
3246
|
+
for (const aid of memberAids) {
|
|
3247
|
+
try {
|
|
3248
|
+
const certPem = await this._fetchPeerCert(aid);
|
|
3249
|
+
const x509Cert = new crypto.X509Certificate(certPem);
|
|
3250
|
+
const pubKey = x509Cert.publicKey;
|
|
3251
|
+
// 导出未压缩 EC 公钥点
|
|
3252
|
+
const jwk = pubKey.export({ format: 'jwk' });
|
|
3253
|
+
if (jwk.crv !== 'P-256' || !jwk.x || !jwk.y)
|
|
3254
|
+
continue;
|
|
3255
|
+
const xBuf = Buffer.from(jwk.x, 'base64url');
|
|
3256
|
+
const yBuf = Buffer.from(jwk.y, 'base64url');
|
|
3257
|
+
const pubkeyBytes = Buffer.concat([Buffer.from([0x04]), xBuf, yBuf]);
|
|
3258
|
+
const ciphertext = eciesEncrypt(pubkeyBytes, groupSecretBytes);
|
|
3259
|
+
encryptedKeys[aid] = ciphertext.toString('base64');
|
|
3260
|
+
}
|
|
3261
|
+
catch (exc) {
|
|
3262
|
+
_clientLog('debug', '为成员 %s 构建 ECIES epoch key 失败: %s', aid, formatCaughtError(exc));
|
|
3263
|
+
continue;
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
return encryptedKeys;
|
|
3267
|
+
}
|
|
3268
|
+
catch (exc) {
|
|
3269
|
+
_clientLog('debug', '构建 encrypted_keys 失败: group=%s err=%s', groupId, formatCaughtError(exc));
|
|
3270
|
+
return {};
|
|
3271
|
+
}
|
|
3272
|
+
}
|
|
2690
3273
|
async _doRecoverGroupEpochKey(groupId, epoch, senderAid, timeoutMs) {
|
|
3274
|
+
// 仅 open / invite_code 群允许从服务端拉取 ECIES 加密的 epoch key
|
|
3275
|
+
if (await this._groupAllowsMemberEpochRotation(groupId)) {
|
|
3276
|
+
if (await this._tryRecoverEpochKeyFromServer(groupId, epoch)) {
|
|
3277
|
+
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
3278
|
+
return true;
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
2691
3281
|
let epochResult = { epoch };
|
|
2692
3282
|
try {
|
|
2693
3283
|
const raw = await this.call('group.e2ee.get_epoch', { group_id: groupId });
|
|
@@ -2701,7 +3291,31 @@ export class AUNClient {
|
|
|
2701
3291
|
const current = Array.isArray(epochResult.recovery_candidates) ? epochResult.recovery_candidates : [];
|
|
2702
3292
|
epochResult.recovery_candidates = [senderAid, ...current];
|
|
2703
3293
|
}
|
|
2704
|
-
|
|
3294
|
+
// 在线优先恢复:先查在线成员列表,只向在线成员发送密钥请求
|
|
3295
|
+
let onlineAids = null;
|
|
3296
|
+
try {
|
|
3297
|
+
const onlineResp = await this.call('group.get_online_members', { group_id: groupId });
|
|
3298
|
+
if (isJsonObject(onlineResp)) {
|
|
3299
|
+
const rawMembers = Array.isArray(onlineResp.members) ? onlineResp.members
|
|
3300
|
+
: Array.isArray(onlineResp.items) ? onlineResp.items : [];
|
|
3301
|
+
onlineAids = rawMembers
|
|
3302
|
+
.filter((m) => isJsonObject(m) && m.online === true && String(m.aid ?? '') !== this._aid)
|
|
3303
|
+
.map(m => String(m.aid ?? ''));
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
catch {
|
|
3307
|
+
_clientLog('debug', '群 %s 查询在线成员失败,回退全量候选', groupId);
|
|
3308
|
+
}
|
|
3309
|
+
if (onlineAids !== null) {
|
|
3310
|
+
if (onlineAids.length === 0) {
|
|
3311
|
+
_clientLog('info', '群 %s epoch %s 恢复失败:无在线成员可请求密钥', groupId, String(epoch));
|
|
3312
|
+
return false;
|
|
3313
|
+
}
|
|
3314
|
+
await this._requestGroupKeyFromOnline(groupId, epoch, onlineAids, epochResult);
|
|
3315
|
+
}
|
|
3316
|
+
else {
|
|
3317
|
+
await this._requestGroupKeyFromCandidates(groupId, epoch, epochResult);
|
|
3318
|
+
}
|
|
2705
3319
|
const deadline = Date.now() + timeoutMs;
|
|
2706
3320
|
while (Date.now() < deadline) {
|
|
2707
3321
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
@@ -2717,6 +3331,22 @@ export class AUNClient {
|
|
|
2717
3331
|
this._scheduleRetryPendingDecryptMsgs(groupId);
|
|
2718
3332
|
return ready;
|
|
2719
3333
|
}
|
|
3334
|
+
/** 只向在线成员发送密钥恢复请求 */
|
|
3335
|
+
async _requestGroupKeyFromOnline(groupId, epoch, onlineAids, epochResult) {
|
|
3336
|
+
const candidates = this._groupKeyRecoveryCandidates(groupId, epochResult);
|
|
3337
|
+
const ordered = [];
|
|
3338
|
+
for (const aid of candidates) {
|
|
3339
|
+
if (onlineAids.includes(aid) && !ordered.includes(aid))
|
|
3340
|
+
ordered.push(aid);
|
|
3341
|
+
}
|
|
3342
|
+
for (const aid of onlineAids) {
|
|
3343
|
+
if (!ordered.includes(aid))
|
|
3344
|
+
ordered.push(aid);
|
|
3345
|
+
}
|
|
3346
|
+
for (const aid of ordered) {
|
|
3347
|
+
await this._requestGroupKeyFrom(groupId, aid, epoch);
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
2720
3350
|
async _groupEpochSecretReadyForRecovery(groupId, epoch, secret) {
|
|
2721
3351
|
if (!isJsonObject(secret))
|
|
2722
3352
|
return false;
|
|
@@ -2847,19 +3477,30 @@ export class AUNClient {
|
|
|
2847
3477
|
payload: payload ?? {},
|
|
2848
3478
|
created_at: Number(item.created_at ?? 0),
|
|
2849
3479
|
};
|
|
3480
|
+
if (isJsonObject(item.context))
|
|
3481
|
+
message.context = item.context;
|
|
2850
3482
|
const decrypted = await this._decryptGroupMessage(message, { skipReplay: true });
|
|
3483
|
+
let decryptFailed = false;
|
|
2851
3484
|
if (payload?.type === 'e2ee.group_encrypted' && groupId && !decrypted.e2ee) {
|
|
2852
|
-
|
|
2853
|
-
|
|
3485
|
+
decryptFailed = true;
|
|
3486
|
+
// 安全网:触发 epoch key 恢复(内部有去重,重复调用安全)
|
|
3487
|
+
const epoch = Number(payload.epoch ?? 0);
|
|
3488
|
+
if (epoch > 0) {
|
|
3489
|
+
this._recoverGroupEpochKey(groupId, epoch, senderAid, 5000).catch(() => { });
|
|
3490
|
+
}
|
|
2854
3491
|
}
|
|
2855
|
-
|
|
3492
|
+
const thought = {
|
|
2856
3493
|
thought_id: thoughtId,
|
|
2857
3494
|
message_id: thoughtId,
|
|
2858
|
-
|
|
2859
|
-
payload: decrypted.payload,
|
|
3495
|
+
payload: decryptFailed ? (payload ?? {}) : decrypted.payload,
|
|
2860
3496
|
created_at: item.created_at,
|
|
2861
3497
|
e2ee: decrypted.e2ee,
|
|
2862
|
-
}
|
|
3498
|
+
};
|
|
3499
|
+
if (decryptFailed)
|
|
3500
|
+
thought.decrypt_failed = true;
|
|
3501
|
+
if ('context' in item)
|
|
3502
|
+
thought.context = item.context;
|
|
3503
|
+
thoughts.push(thought);
|
|
2863
3504
|
}
|
|
2864
3505
|
return { ...result, thoughts };
|
|
2865
3506
|
}
|
|
@@ -2887,31 +3528,41 @@ export class AUNClient {
|
|
|
2887
3528
|
encrypted: item.encrypted !== false,
|
|
2888
3529
|
timestamp: Number(item.created_at ?? 0),
|
|
2889
3530
|
};
|
|
3531
|
+
if (isJsonObject(item.context))
|
|
3532
|
+
message.context = item.context;
|
|
2890
3533
|
let decrypted = message;
|
|
3534
|
+
let decryptFailed = false;
|
|
2891
3535
|
if (payload?.type === 'e2ee.encrypted') {
|
|
2892
3536
|
const senderCertFingerprint = String(payload.sender_cert_fingerprint ?? payload.aad?.sender_cert_fingerprint ?? '').trim().toLowerCase();
|
|
2893
3537
|
if (fromAid) {
|
|
2894
3538
|
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
2895
3539
|
if (!certReady) {
|
|
2896
3540
|
_clientLog('warn', '无法获取发送方 %s 的证书,跳过 message.thought.get 解密', fromAid);
|
|
2897
|
-
|
|
3541
|
+
decryptFailed = true;
|
|
2898
3542
|
}
|
|
2899
3543
|
}
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
3544
|
+
if (!decryptFailed) {
|
|
3545
|
+
decrypted = this._e2ee._decryptMessage(message);
|
|
3546
|
+
if (decrypted === null || (isJsonObject(decrypted.payload) && decrypted.payload.type === 'e2ee.encrypted')) {
|
|
3547
|
+
decryptFailed = true;
|
|
3548
|
+
decrypted = message;
|
|
3549
|
+
}
|
|
2903
3550
|
}
|
|
2904
3551
|
}
|
|
2905
|
-
|
|
3552
|
+
const thought = {
|
|
2906
3553
|
thought_id: thoughtId,
|
|
2907
3554
|
message_id: thoughtId,
|
|
2908
|
-
reply_to: item.reply_to,
|
|
2909
3555
|
from: fromAid,
|
|
2910
3556
|
to: toAid,
|
|
2911
|
-
payload: decrypted.payload,
|
|
3557
|
+
payload: decryptFailed ? (payload ?? {}) : decrypted.payload,
|
|
2912
3558
|
created_at: item.created_at,
|
|
2913
|
-
e2ee: decrypted.e2ee,
|
|
2914
|
-
}
|
|
3559
|
+
e2ee: decryptFailed ? undefined : decrypted.e2ee,
|
|
3560
|
+
};
|
|
3561
|
+
if (decryptFailed)
|
|
3562
|
+
thought.decrypt_failed = true;
|
|
3563
|
+
if ('context' in item)
|
|
3564
|
+
thought.context = item.context;
|
|
3565
|
+
thoughts.push(thought);
|
|
2915
3566
|
}
|
|
2916
3567
|
return { ...result, thoughts };
|
|
2917
3568
|
}
|
|
@@ -3028,8 +3679,17 @@ export class AUNClient {
|
|
|
3028
3679
|
if (Number.isFinite(epoch) && epoch > 0 && epoch <= committedEpoch) {
|
|
3029
3680
|
if (committedRotation && Number(committedRotation.target_epoch ?? committedEpoch) === epoch) {
|
|
3030
3681
|
const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
|
|
3031
|
-
if (committedCommitment && commitment && committedCommitment !== commitment)
|
|
3032
|
-
|
|
3682
|
+
if (committedCommitment && commitment && committedCommitment !== commitment) {
|
|
3683
|
+
const expectedMembers = Array.isArray(committedRotation.expected_members)
|
|
3684
|
+
? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean)
|
|
3685
|
+
: [];
|
|
3686
|
+
if (this._aid && !expectedMembers.includes(this._aid)) {
|
|
3687
|
+
_clientLog('debug', '放行 group key 分发:新成员恢复 commitment 不匹配属正常 group=%s epoch=%s', groupId, epoch);
|
|
3688
|
+
}
|
|
3689
|
+
else {
|
|
3690
|
+
return false;
|
|
3691
|
+
}
|
|
3692
|
+
}
|
|
3033
3693
|
}
|
|
3034
3694
|
return true;
|
|
3035
3695
|
}
|
|
@@ -3098,8 +3758,17 @@ export class AUNClient {
|
|
|
3098
3758
|
const committedRotation = isJsonObject(epochResult.committed_rotation) ? epochResult.committed_rotation : null;
|
|
3099
3759
|
if (committedRotation && Number(committedRotation.target_epoch ?? committedEpoch) === epoch) {
|
|
3100
3760
|
const committedCommitment = String(committedRotation.key_commitment ?? '').trim();
|
|
3101
|
-
if (committedCommitment && commitment && committedCommitment !== commitment)
|
|
3102
|
-
|
|
3761
|
+
if (committedCommitment && commitment && committedCommitment !== commitment) {
|
|
3762
|
+
const expectedMembers = Array.isArray(committedRotation.expected_members)
|
|
3763
|
+
? committedRotation.expected_members.map((item) => String(item ?? '').trim()).filter(Boolean)
|
|
3764
|
+
: [];
|
|
3765
|
+
if (this._aid && !expectedMembers.includes(this._aid)) {
|
|
3766
|
+
_clientLog('debug', '放行 group key response:新成员恢复 commitment 不匹配属正常 group=%s epoch=%s', groupId, epoch);
|
|
3767
|
+
}
|
|
3768
|
+
else {
|
|
3769
|
+
return false;
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3103
3772
|
}
|
|
3104
3773
|
return true;
|
|
3105
3774
|
}
|
|
@@ -3207,7 +3876,15 @@ export class AUNClient {
|
|
|
3207
3876
|
await this._abortGroupRotation(activeRotationId, 'self_ack_failed');
|
|
3208
3877
|
return;
|
|
3209
3878
|
}
|
|
3210
|
-
const
|
|
3879
|
+
const commitParams2 = { rotation_id: activeRotationId };
|
|
3880
|
+
const createMembers = secretData.member_aids.length > 0 ? secretData.member_aids : (this._aid ? [this._aid] : []);
|
|
3881
|
+
const encKeys2 = await this._buildEpochEncryptedKeys({ distributions: [{ payload: { group_secret: secretData.secret.toString('base64') } }] }, createMembers, 1, groupId);
|
|
3882
|
+
if (await this._groupAllowsMemberEpochRotation(groupId)) {
|
|
3883
|
+
if (encKeys2 && Object.keys(encKeys2).length > 0) {
|
|
3884
|
+
commitParams2.encrypted_keys = encKeys2;
|
|
3885
|
+
}
|
|
3886
|
+
}
|
|
3887
|
+
const commitResult = await this.call('group.e2ee.commit_rotation', commitParams2);
|
|
3211
3888
|
if (isJsonObject(commitResult) && commitResult.success === true) {
|
|
3212
3889
|
storeGroupSecret(this._keystore, this._aid, groupId, 1, secretData.secret, secretData.commitment, secretData.member_aids, secretData.epoch_chain);
|
|
3213
3890
|
return;
|
|
@@ -3273,8 +3950,23 @@ export class AUNClient {
|
|
|
3273
3950
|
}
|
|
3274
3951
|
const currentEpoch = expectedEpoch ?? serverEpoch;
|
|
3275
3952
|
const targetEpoch = currentEpoch + 1;
|
|
3953
|
+
// 新成员可能没有 prev epoch key,或有 key 但缺少 epoch_chain(通过 backfill 接收)。
|
|
3954
|
+
// 从 committed_rotation.epoch_chain 获取 prev chain hint。
|
|
3955
|
+
let prevChainHint = null;
|
|
3956
|
+
const localPrev = this._groupE2ee.loadSecret(groupId, currentEpoch);
|
|
3957
|
+
const localPrevChain = String(localPrev?.epoch_chain ?? '');
|
|
3958
|
+
if (!localPrevChain && isJsonObject(epochResult)) {
|
|
3959
|
+
const cr = epochResult.committed_rotation;
|
|
3960
|
+
if (isJsonObject(cr)) {
|
|
3961
|
+
const rawChain = String(cr.epoch_chain ?? '').trim();
|
|
3962
|
+
if (rawChain) {
|
|
3963
|
+
prevChainHint = rawChain;
|
|
3964
|
+
_clientLog('info', '新成员轮换补充 prev epoch chain from server: group=%s epoch=%d', groupId, currentEpoch);
|
|
3965
|
+
}
|
|
3966
|
+
}
|
|
3967
|
+
}
|
|
3276
3968
|
const rotationId = `rot-${crypto.randomUUID().replace(/-/g, '')}`;
|
|
3277
|
-
const info = this._groupE2ee.rotateEpochTo(groupId, targetEpoch, memberAids, { rotationId });
|
|
3969
|
+
const info = this._groupE2ee.rotateEpochTo(groupId, targetEpoch, memberAids, { rotationId, prevChainHint });
|
|
3278
3970
|
this._attachRotationId(info, rotationId);
|
|
3279
3971
|
const discardGeneratedPending = () => {
|
|
3280
3972
|
try {
|
|
@@ -3366,7 +4058,15 @@ export class AUNClient {
|
|
|
3366
4058
|
discardGeneratedPending();
|
|
3367
4059
|
return;
|
|
3368
4060
|
}
|
|
3369
|
-
const
|
|
4061
|
+
const commitParams = { rotation_id: activeRotationId };
|
|
4062
|
+
// 构建 per-member ECIES 加密的 epoch key 上传到服务端
|
|
4063
|
+
if (await this._groupAllowsMemberEpochRotation(groupId)) {
|
|
4064
|
+
const encryptedKeys = await this._buildEpochEncryptedKeys(info, memberAids, targetEpoch, groupId);
|
|
4065
|
+
if (encryptedKeys && Object.keys(encryptedKeys).length > 0) {
|
|
4066
|
+
commitParams.encrypted_keys = encryptedKeys;
|
|
4067
|
+
}
|
|
4068
|
+
}
|
|
4069
|
+
const commitResult = await this.call('group.e2ee.commit_rotation', commitParams);
|
|
3370
4070
|
if (!isJsonObject(commitResult) || commitResult.success !== true) {
|
|
3371
4071
|
_clientLog('warn', 'group epoch commit failed (group=%s, rotation=%s, returned=%s)', groupId, activeRotationId, JSON.stringify(commitResult));
|
|
3372
4072
|
this._scheduleGroupRotationRetry(groupId, {
|
|
@@ -3434,7 +4134,7 @@ export class AUNClient {
|
|
|
3434
4134
|
if (identity && identity.private_key_pem) {
|
|
3435
4135
|
manifest = signMembershipManifest(manifest, String(identity.private_key_pem));
|
|
3436
4136
|
}
|
|
3437
|
-
const distPayload = buildKeyDistribution(groupId, epoch, secretData.secret, memberAids, this._aid ?? '', manifest);
|
|
4137
|
+
const distPayload = buildKeyDistribution(groupId, epoch, secretData.secret, memberAids, this._aid ?? '', manifest, String(secretData.epoch_chain ?? ''));
|
|
3438
4138
|
// 重试 3 次,间隔递增(1s, 2s)
|
|
3439
4139
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
3440
4140
|
try {
|
|
@@ -3460,7 +4160,79 @@ export class AUNClient {
|
|
|
3460
4160
|
this._logE2eeError('distribute_key', groupId, newMemberAid, exc);
|
|
3461
4161
|
}
|
|
3462
4162
|
}
|
|
3463
|
-
/**
|
|
4163
|
+
/** 从成员加入事件 payload 中提取新加入的成员 AID 列表。 */
|
|
4164
|
+
_joinedMemberAidsFromPayload(payload) {
|
|
4165
|
+
const aids = new Set();
|
|
4166
|
+
const addAid = (value) => {
|
|
4167
|
+
const aid = String(value ?? '').trim();
|
|
4168
|
+
if (aid)
|
|
4169
|
+
aids.add(aid);
|
|
4170
|
+
};
|
|
4171
|
+
addAid(payload.aid ?? payload.applicant_aid ?? payload.applicantAid);
|
|
4172
|
+
addAid(payload.actor_aid);
|
|
4173
|
+
for (const key of ['member_aid', 'target_aid', 'new_member_aid', 'used_by']) {
|
|
4174
|
+
addAid(payload[key]);
|
|
4175
|
+
}
|
|
4176
|
+
for (const key of ['member', 'request', 'invite_code']) {
|
|
4177
|
+
const nested = isJsonObject(payload[key]) ? payload[key] : null;
|
|
4178
|
+
if (!nested)
|
|
4179
|
+
continue;
|
|
4180
|
+
addAid(nested.aid ?? nested.applicant_aid ?? nested.applicantAid);
|
|
4181
|
+
for (const nk of ['member_aid', 'target_aid', 'used_by'])
|
|
4182
|
+
addAid(nested[nk]);
|
|
4183
|
+
}
|
|
4184
|
+
if (Array.isArray(payload.results)) {
|
|
4185
|
+
for (const item of payload.results) {
|
|
4186
|
+
if (!isJsonObject(item))
|
|
4187
|
+
continue;
|
|
4188
|
+
const obj = item;
|
|
4189
|
+
const status = String(obj.status ?? '').trim().toLowerCase();
|
|
4190
|
+
if (status !== 'approved' && obj.approved !== true)
|
|
4191
|
+
continue;
|
|
4192
|
+
addAid(obj.aid ?? obj.applicant_aid ?? obj.applicantAid);
|
|
4193
|
+
for (const key of ['member_aid', 'target_aid'])
|
|
4194
|
+
addAid(obj[key]);
|
|
4195
|
+
for (const key of ['member', 'request']) {
|
|
4196
|
+
const nested = isJsonObject(obj[key]) ? obj[key] : null;
|
|
4197
|
+
if (!nested)
|
|
4198
|
+
continue;
|
|
4199
|
+
addAid(nested.aid ?? nested.applicant_aid);
|
|
4200
|
+
for (const nk of ['member_aid', 'target_aid'])
|
|
4201
|
+
addAid(nested[nk]);
|
|
4202
|
+
}
|
|
4203
|
+
}
|
|
4204
|
+
}
|
|
4205
|
+
return Array.from(aids);
|
|
4206
|
+
}
|
|
4207
|
+
// ── 入群密钥恢复策略 ──────────────────────────────────────
|
|
4208
|
+
/** 延迟轮换等待时间(毫秒):给新成员恢复 committed_epoch 的窗口 */
|
|
4209
|
+
static _JOIN_ROTATION_DELAY_MS = 3000;
|
|
4210
|
+
// 新成员自身延迟轮换时间:优先让其他在线成员先轮换
|
|
4211
|
+
static _SELF_JOIN_ROTATION_DELAY_MS = 6000;
|
|
4212
|
+
/** open/invite_code 入群后延迟轮换。 */
|
|
4213
|
+
async _delayedRotateAfterJoin(groupId, triggerId, expectedEpoch, allowMember = false, delayMs) {
|
|
4214
|
+
await new Promise(resolve => setTimeout(resolve, delayMs ?? AUNClient._JOIN_ROTATION_DELAY_MS));
|
|
4215
|
+
await this._maybeLeadRotateGroupEpoch(groupId, triggerId, expectedEpoch, allowMember);
|
|
4216
|
+
}
|
|
4217
|
+
/** 当新成员加入但缺少 old_epoch 时,将当前 epoch 密钥分发给新成员。 */
|
|
4218
|
+
async _maybeBackfillKeyToJoinedMember(groupId, payload, triggerId = '') {
|
|
4219
|
+
const memberAids = this._joinedMemberAidsFromPayload(payload)
|
|
4220
|
+
.filter(aid => aid && aid !== this._aid);
|
|
4221
|
+
if (!groupId || !this._aid || memberAids.length === 0)
|
|
4222
|
+
return;
|
|
4223
|
+
if (!this._groupE2ee.hasSecret(groupId))
|
|
4224
|
+
return;
|
|
4225
|
+
for (const memberAid of memberAids) {
|
|
4226
|
+
const dedupeKey = `${triggerId || this._membershipRotationTriggerId(groupId, payload)}:backfill:${memberAid}`;
|
|
4227
|
+
if (this._groupMemberKeyBackfillDone.has(dedupeKey))
|
|
4228
|
+
continue;
|
|
4229
|
+
this._groupMemberKeyBackfillDone.add(dedupeKey);
|
|
4230
|
+
if (this._groupMemberKeyBackfillDone.size > 2000) {
|
|
4231
|
+
this._groupMemberKeyBackfillDone = new Set(Array.from(this._groupMemberKeyBackfillDone).slice(-1000));
|
|
4232
|
+
}
|
|
4233
|
+
await this._distributeKeyToNewMember(groupId, memberAid);
|
|
4234
|
+
}
|
|
4235
|
+
}
|
|
3464
4236
|
_buildRotationSignature(groupId, currentEpoch, newEpoch = 0, source) {
|
|
3465
4237
|
const identity = this._identity;
|
|
3466
4238
|
if (!identity || !identity.private_key_pem) {
|
|
@@ -3823,8 +4595,6 @@ export class AUNClient {
|
|
|
3823
4595
|
this._startHeartbeatTask();
|
|
3824
4596
|
this._startTokenRefreshTask();
|
|
3825
4597
|
this._startGroupEpochTasks();
|
|
3826
|
-
// 上线/重连后一次性补齐群消息和群事件
|
|
3827
|
-
this._syncAllGroupsOnce().catch(exc => _clientLog('warn', '后台补洞触发失败: %s', formatCaughtError(exc)));
|
|
3828
4598
|
}
|
|
3829
4599
|
/** 停止所有后台任务 */
|
|
3830
4600
|
_stopBackgroundTasks() {
|
|
@@ -3861,9 +4631,10 @@ export class AUNClient {
|
|
|
3861
4631
|
_startHeartbeatTask() {
|
|
3862
4632
|
if (this._heartbeatTimer !== null)
|
|
3863
4633
|
return;
|
|
3864
|
-
const
|
|
3865
|
-
if (
|
|
4634
|
+
const rawIntervalSeconds = Number(this._sessionOptions.heartbeat_interval ?? DEFAULT_SESSION_OPTIONS.heartbeat_interval);
|
|
4635
|
+
if (!Number.isFinite(rawIntervalSeconds) || rawIntervalSeconds <= 0)
|
|
3866
4636
|
return;
|
|
4637
|
+
const interval = Math.max(rawIntervalSeconds, 30) * 1000;
|
|
3867
4638
|
// M25: 把连续失败阈值从 3 次收窄到 2 次。既能容忍一次网络抖动/GC 暂停,
|
|
3868
4639
|
// 又把半开连接的检测延迟从 3 个心跳周期降到 2 个。
|
|
3869
4640
|
// 真正的 socket 死亡由 ws.on('close') 立即触发 _handleTransportDisconnect,
|
|
@@ -3894,31 +4665,36 @@ export class AUNClient {
|
|
|
3894
4665
|
_startTokenRefreshTask() {
|
|
3895
4666
|
if (this._tokenRefreshTimer !== null)
|
|
3896
4667
|
return;
|
|
3897
|
-
const
|
|
3898
|
-
const
|
|
3899
|
-
|
|
4668
|
+
const rawLead = Number(this._sessionOptions.token_refresh_before ?? DEFAULT_SESSION_OPTIONS.token_refresh_before);
|
|
4669
|
+
const lead = Number.isFinite(rawLead) && rawLead > 0
|
|
4670
|
+
? rawLead
|
|
4671
|
+
: DEFAULT_SESSION_OPTIONS.token_refresh_before;
|
|
4672
|
+
const scheduleNext = (delayMs = TOKEN_REFRESH_CHECK_INTERVAL_MS) => {
|
|
3900
4673
|
if (this._closing)
|
|
3901
4674
|
return;
|
|
3902
|
-
if (this._state !== 'connected' || !this._gatewayUrl) {
|
|
3903
|
-
this._tokenRefreshTimer = setTimeout(scheduleNext, minimumSleep);
|
|
3904
|
-
this._unrefTimer(this._tokenRefreshTimer);
|
|
3905
|
-
return;
|
|
3906
|
-
}
|
|
3907
|
-
let identity = this._identity ?? this._auth.loadIdentityOrNone() ?? null;
|
|
3908
|
-
if (identity === null) {
|
|
3909
|
-
this._tokenRefreshTimer = setTimeout(scheduleNext, minimumSleep);
|
|
3910
|
-
this._unrefTimer(this._tokenRefreshTimer);
|
|
3911
|
-
return;
|
|
3912
|
-
}
|
|
3913
|
-
this._identity = identity;
|
|
3914
|
-
const expiresAt = this._auth.getAccessTokenExpiry(identity);
|
|
3915
|
-
if (expiresAt === null) {
|
|
3916
|
-
this._tokenRefreshTimer = setTimeout(scheduleNext, minimumSleep);
|
|
3917
|
-
this._unrefTimer(this._tokenRefreshTimer);
|
|
3918
|
-
return;
|
|
3919
|
-
}
|
|
3920
|
-
const delay = Math.max((expiresAt - lead - Date.now() / 1000) * 1000, minimumSleep);
|
|
3921
4675
|
this._tokenRefreshTimer = setTimeout(async () => {
|
|
4676
|
+
if (this._closing)
|
|
4677
|
+
return;
|
|
4678
|
+
this._tokenRefreshTimer = null;
|
|
4679
|
+
if (this._state !== 'connected' || !this._gatewayUrl) {
|
|
4680
|
+
scheduleNext();
|
|
4681
|
+
return;
|
|
4682
|
+
}
|
|
4683
|
+
let identity = this._identity ?? this._auth.loadIdentityOrNone() ?? null;
|
|
4684
|
+
if (identity === null) {
|
|
4685
|
+
scheduleNext();
|
|
4686
|
+
return;
|
|
4687
|
+
}
|
|
4688
|
+
this._identity = identity;
|
|
4689
|
+
const expiresAt = this._auth.getAccessTokenExpiry(identity);
|
|
4690
|
+
if (expiresAt === null) {
|
|
4691
|
+
scheduleNext();
|
|
4692
|
+
return;
|
|
4693
|
+
}
|
|
4694
|
+
if ((expiresAt - Date.now() / 1000) > lead) {
|
|
4695
|
+
scheduleNext();
|
|
4696
|
+
return;
|
|
4697
|
+
}
|
|
3922
4698
|
if (this._closing || this._state !== 'connected' || !this._gatewayUrl) {
|
|
3923
4699
|
scheduleNext();
|
|
3924
4700
|
return;
|
|
@@ -3956,10 +4732,10 @@ export class AUNClient {
|
|
|
3956
4732
|
}
|
|
3957
4733
|
}
|
|
3958
4734
|
scheduleNext();
|
|
3959
|
-
},
|
|
4735
|
+
}, delayMs);
|
|
3960
4736
|
this._unrefTimer(this._tokenRefreshTimer);
|
|
3961
4737
|
};
|
|
3962
|
-
scheduleNext();
|
|
4738
|
+
scheduleNext(0);
|
|
3963
4739
|
}
|
|
3964
4740
|
/** 启动 prekey 刷新任务 */
|
|
3965
4741
|
_startPrekeyRefreshTask() {
|
|
@@ -3997,10 +4773,12 @@ export class AUNClient {
|
|
|
3997
4773
|
}
|
|
3998
4774
|
if (method === 'group.thought.put' || method === 'group.thought.get'
|
|
3999
4775
|
|| method === 'message.thought.put' || method === 'message.thought.get') {
|
|
4000
|
-
const
|
|
4001
|
-
const
|
|
4002
|
-
|
|
4003
|
-
|
|
4776
|
+
const context = isJsonObject(params.context) ? params.context : null;
|
|
4777
|
+
const contextType = String(context?.type ?? '').trim();
|
|
4778
|
+
const contextId = String(context?.id ?? '').trim();
|
|
4779
|
+
const hasContext = contextType.length > 0 && contextId.length > 0;
|
|
4780
|
+
if (!hasContext) {
|
|
4781
|
+
throw new ValidationError(`${method} requires context.type + context.id`);
|
|
4004
4782
|
}
|
|
4005
4783
|
}
|
|
4006
4784
|
if (method === 'group.thought.get' && !String(params.sender_aid ?? '').trim()) {
|
|
@@ -4266,6 +5044,69 @@ export class AUNClient {
|
|
|
4266
5044
|
}
|
|
4267
5045
|
this._reconnectActive = false;
|
|
4268
5046
|
}
|
|
5047
|
+
// ── Named Group(命名群)高层 API ────────────────────────────
|
|
5048
|
+
/**
|
|
5049
|
+
* 创建命名群:本地生成 P-256 keypair,调用 group.create 传入 public_key,
|
|
5050
|
+
* 服务端签发群 AID 证书,返回后将证书和私钥存入 keystore。
|
|
5051
|
+
*/
|
|
5052
|
+
async createNamedGroup(groupName, opts = {}) {
|
|
5053
|
+
const cp = new CryptoProvider();
|
|
5054
|
+
const identity = cp.generateIdentity();
|
|
5055
|
+
const params = {};
|
|
5056
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
5057
|
+
params[k] = v;
|
|
5058
|
+
}
|
|
5059
|
+
params.group_name = groupName;
|
|
5060
|
+
params.public_key = identity.public_key_der_b64;
|
|
5061
|
+
params.curve = 'P-256';
|
|
5062
|
+
const result = await this.call('group.create', params);
|
|
5063
|
+
const groupInfo = result?.group;
|
|
5064
|
+
const aidCert = result?.aid_cert;
|
|
5065
|
+
const groupAid = String(groupInfo?.group_aid ?? '');
|
|
5066
|
+
if (groupAid && aidCert) {
|
|
5067
|
+
this._keystore.saveIdentity(groupAid, {
|
|
5068
|
+
private_key_pem: identity.private_key_pem,
|
|
5069
|
+
public_key: identity.public_key_der_b64,
|
|
5070
|
+
curve: 'P-256',
|
|
5071
|
+
type: 'group_identity',
|
|
5072
|
+
});
|
|
5073
|
+
const certPem = String(aidCert.cert ?? '');
|
|
5074
|
+
if (certPem) {
|
|
5075
|
+
this._keystore.saveCert(groupAid, certPem);
|
|
5076
|
+
}
|
|
5077
|
+
}
|
|
5078
|
+
return result;
|
|
5079
|
+
}
|
|
5080
|
+
/**
|
|
5081
|
+
* 为已有普通群绑定命名 AID(升级为命名群)。
|
|
5082
|
+
*/
|
|
5083
|
+
async bindGroupAid(groupId, groupName) {
|
|
5084
|
+
const cp = new CryptoProvider();
|
|
5085
|
+
const identity = cp.generateIdentity();
|
|
5086
|
+
const params = {
|
|
5087
|
+
group_id: groupId,
|
|
5088
|
+
group_name: groupName,
|
|
5089
|
+
public_key: identity.public_key_der_b64,
|
|
5090
|
+
curve: 'P-256',
|
|
5091
|
+
};
|
|
5092
|
+
const result = await this.call('group.bind_aid', params);
|
|
5093
|
+
const groupInfo = result?.group;
|
|
5094
|
+
const aidCert = result?.aid_cert;
|
|
5095
|
+
const groupAid = String(groupInfo?.group_aid ?? '');
|
|
5096
|
+
if (groupAid && aidCert) {
|
|
5097
|
+
this._keystore.saveIdentity(groupAid, {
|
|
5098
|
+
private_key_pem: identity.private_key_pem,
|
|
5099
|
+
public_key: identity.public_key_der_b64,
|
|
5100
|
+
curve: 'P-256',
|
|
5101
|
+
type: 'group_identity',
|
|
5102
|
+
});
|
|
5103
|
+
const certPem = String(aidCert.cert ?? '');
|
|
5104
|
+
if (certPem) {
|
|
5105
|
+
this._keystore.saveCert(groupAid, certPem);
|
|
5106
|
+
}
|
|
5107
|
+
}
|
|
5108
|
+
return result;
|
|
5109
|
+
}
|
|
4269
5110
|
/** 判断是否应重试重连 */
|
|
4270
5111
|
static _shouldRetryReconnect(error) {
|
|
4271
5112
|
if (error instanceof AuthError) {
|