@agentunion/fastaun-browser 0.4.6 → 0.4.8
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/CHANGELOG.md +45 -0
- package/_packed_docs/CHANGELOG.md +45 -0
- package/_packed_docs/INDEX.md +46 -22
- package/_packed_docs/KITE_DOCS_GUIDE.md +16 -12
- package/_packed_docs/design/AUNClient/346/213/206/345/210/206/351/207/215/346/236/204/346/211/247/350/241/214/346/226/271/346/241/210.md +859 -0
- package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +5 -3
- package/_packed_docs/sdk/05-E2EE/345/212/240/345/257/206/351/200/232/344/277/241.md +5 -5
- package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +4 -2
- package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +1 -1
- package/dist/agent-md.d.ts +1 -1
- package/dist/agent-md.d.ts.map +1 -1
- package/dist/agent-md.js +33 -8
- package/dist/agent-md.js.map +1 -1
- package/dist/aid-store.d.ts +8 -1
- package/dist/aid-store.d.ts.map +1 -1
- package/dist/aid-store.js +109 -16
- package/dist/aid-store.js.map +1 -1
- package/dist/aid.d.ts +1 -0
- package/dist/aid.d.ts.map +1 -1
- package/dist/aid.js +10 -4
- package/dist/aid.js.map +1 -1
- package/dist/bundle.js +9924 -8897
- package/dist/cert-utils.d.ts +5 -1
- package/dist/cert-utils.d.ts.map +1 -1
- package/dist/cert-utils.js +52 -8
- package/dist/cert-utils.js.map +1 -1
- package/dist/client/delivery.d.ts +44 -0
- package/dist/client/delivery.d.ts.map +1 -0
- package/dist/client/delivery.js +1033 -0
- package/dist/client/delivery.js.map +1 -0
- package/dist/client/group-state.d.ts +32 -0
- package/dist/client/group-state.d.ts.map +1 -0
- package/dist/client/group-state.js +909 -0
- package/dist/client/group-state.js.map +1 -0
- package/dist/client/identity.d.ts +8 -0
- package/dist/client/identity.d.ts.map +1 -0
- package/dist/client/identity.js +34 -0
- package/dist/client/identity.js.map +1 -0
- package/dist/client/lifecycle.d.ts +10 -0
- package/dist/client/lifecycle.d.ts.map +1 -0
- package/dist/client/lifecycle.js +222 -0
- package/dist/client/lifecycle.js.map +1 -0
- package/dist/client/peers.d.ts +11 -0
- package/dist/client/peers.d.ts.map +1 -0
- package/dist/client/peers.js +42 -0
- package/dist/client/peers.js.map +1 -0
- package/dist/client/rpc-pipeline.d.ts +36 -0
- package/dist/client/rpc-pipeline.d.ts.map +1 -0
- package/dist/client/rpc-pipeline.js +446 -0
- package/dist/client/rpc-pipeline.js.map +1 -0
- package/dist/client/runtime.d.ts +6 -0
- package/dist/client/runtime.d.ts.map +1 -0
- package/dist/client/runtime.js +7 -0
- package/dist/client/runtime.js.map +1 -0
- package/dist/client/v2-e2ee.d.ts +104 -0
- package/dist/client/v2-e2ee.d.ts.map +1 -0
- package/dist/client/v2-e2ee.js +1586 -0
- package/dist/client/v2-e2ee.js.map +1 -0
- package/dist/client.d.ts +26 -49
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +303 -3733
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -3
- package/dist/config.js.map +1 -1
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +69 -10
- package/dist/discovery.js.map +1 -1
- package/dist/keystore/indexeddb-identity-store.d.ts.map +1 -1
- package/dist/keystore/indexeddb-identity-store.js +23 -22
- package/dist/keystore/indexeddb-identity-store.js.map +1 -1
- package/dist/v2/crypto/dh-path.js +2 -2
- package/dist/v2/crypto/index.js +7 -7
- package/dist/v2/e2ee/decrypt.js +8 -8
- package/dist/v2/e2ee/encrypt-group.js +9 -9
- package/dist/v2/e2ee/encrypt-p2p.js +9 -9
- package/dist/v2/e2ee/index.js +5 -5
- package/dist/v2/e2ee/metadata-auth.js +1 -1
- package/dist/v2/session/index.js +2 -2
- package/dist/v2/session/session.js +1 -1
- package/dist/v2/state/commitment.js +1 -1
- package/dist/v2/state/index.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/client.js
CHANGED
|
@@ -4,46 +4,31 @@
|
|
|
4
4
|
// - HTTP 使用 fetch() 而非 Node http
|
|
5
5
|
// - 无文件系统(IndexedDB via keystore)
|
|
6
6
|
// - 后台任务使用 setTimeout/setInterval
|
|
7
|
-
import { createConfig, getDeviceId, normalizeSlotId
|
|
7
|
+
import { createConfig, getDeviceId, normalizeSlotId } from './config.js';
|
|
8
8
|
import { EventDispatcher } from './events.js';
|
|
9
9
|
import { normalizeGroupId } from './group-id.js';
|
|
10
10
|
import { GatewayDiscovery } from './discovery.js';
|
|
11
11
|
import { RPCTransport } from './transport.js';
|
|
12
12
|
import { AuthFlow } from './auth.js';
|
|
13
13
|
import { SeqTracker } from './seq-tracker.js';
|
|
14
|
-
import {
|
|
14
|
+
import { ClientRuntime } from './client/runtime.js';
|
|
15
|
+
import { MessageDeliveryEngine } from './client/delivery.js';
|
|
16
|
+
import { IdentityRuntimeManager } from './client/identity.js';
|
|
17
|
+
import { LifecycleController } from './client/lifecycle.js';
|
|
18
|
+
import { PeerDirectory } from './client/peers.js';
|
|
19
|
+
import { RpcPipeline } from './client/rpc-pipeline.js';
|
|
20
|
+
import { V2E2EECoordinator } from './client/v2-e2ee.js';
|
|
21
|
+
import { GroupStateCoordinator } from './client/group-state.js';
|
|
22
|
+
import { CryptoProvider, base64ToUint8, pemToArrayBuffer, ecdsaVerifyDer, importCertPublicKeyEcdsa, } from './crypto.js';
|
|
15
23
|
import { IndexedDBTokenStore } from './keystore/indexeddb-token-store.js';
|
|
16
|
-
import {
|
|
17
|
-
import { encryptP2PMessage, encryptGroupMessage, decryptMessage, } from './v2/e2ee/index.js';
|
|
24
|
+
import { decryptMessage, } from './v2/e2ee/index.js';
|
|
18
25
|
import { ecdsaVerifyRaw } from './v2/crypto/ecdsa.js';
|
|
19
|
-
import { computeStateCommitment } from './v2/state/index.js';
|
|
20
26
|
import { AUNLogger } from './logger.js';
|
|
21
27
|
import { AgentMdManager } from './agent-md.js';
|
|
28
|
+
import { certMatchesFingerprint, normalizeFingerprintHex } from './cert-utils.js';
|
|
22
29
|
import { AUNError, AuthError, ConnectionError, E2EEError, NotFoundError, PermissionError, StateError, ValidationError, } from './errors.js';
|
|
23
30
|
import { isJsonObject, ConnectionState, STATE_TO_PUBLIC, } from './types.js';
|
|
24
31
|
import { AID } from './aid.js';
|
|
25
|
-
/**
|
|
26
|
-
* 递归排序键的 JSON 序列化(Canonical JSON for AUN)
|
|
27
|
-
* 等价于 Python json.dumps(sort_keys=True, separators=(",",":"), ensure_ascii=False)
|
|
28
|
-
* 非 ASCII 字符直接以 UTF-8 输出。
|
|
29
|
-
*/
|
|
30
|
-
function stableStringify(obj) {
|
|
31
|
-
if (obj === null || obj === undefined)
|
|
32
|
-
return 'null';
|
|
33
|
-
if (typeof obj === 'boolean' || typeof obj === 'number')
|
|
34
|
-
return JSON.stringify(obj);
|
|
35
|
-
if (typeof obj === 'string')
|
|
36
|
-
return JSON.stringify(obj);
|
|
37
|
-
if (Array.isArray(obj)) {
|
|
38
|
-
return '[' + obj.map(v => stableStringify(v)).join(',') + ']';
|
|
39
|
-
}
|
|
40
|
-
if (isJsonObject(obj)) {
|
|
41
|
-
const keys = Object.keys(obj).sort();
|
|
42
|
-
const entries = keys.map(k => stableStringify(k) + ':' + stableStringify(obj[k]));
|
|
43
|
-
return '{' + entries.join(',') + '}';
|
|
44
|
-
}
|
|
45
|
-
return JSON.stringify(obj);
|
|
46
|
-
}
|
|
47
32
|
function getV2DeviceId(dev) {
|
|
48
33
|
if (Object.prototype.hasOwnProperty.call(dev, 'device_id')) {
|
|
49
34
|
return { present: true, value: String(dev.device_id ?? '').trim() };
|
|
@@ -61,107 +46,22 @@ function isAIDObject(value) {
|
|
|
61
46
|
&& typeof candidate.aunPath === 'string'
|
|
62
47
|
&& typeof candidate.isPrivateKeyValid === 'function');
|
|
63
48
|
}
|
|
64
|
-
function sortObjectKeys(obj) {
|
|
65
|
-
if (obj === null || obj === undefined || typeof obj !== 'object')
|
|
66
|
-
return obj;
|
|
67
|
-
if (Array.isArray(obj))
|
|
68
|
-
return obj.map(sortObjectKeys);
|
|
69
|
-
const sorted = {};
|
|
70
|
-
for (const key of Object.keys(obj).sort()) {
|
|
71
|
-
sorted[key] = sortObjectKeys(obj[key]);
|
|
72
|
-
}
|
|
73
|
-
return sorted;
|
|
74
|
-
}
|
|
75
|
-
function uint64BE(value) {
|
|
76
|
-
const buf = new ArrayBuffer(8);
|
|
77
|
-
new DataView(buf).setBigUint64(0, BigInt(value), false);
|
|
78
|
-
return new Uint8Array(buf);
|
|
79
|
-
}
|
|
80
|
-
function hexToBytes(hex) {
|
|
81
|
-
const clean = hex.trim();
|
|
82
|
-
if (!clean)
|
|
83
|
-
return new Uint8Array(0);
|
|
84
|
-
if (clean.length % 2 !== 0)
|
|
85
|
-
throw new ValidationError('invalid hex length');
|
|
86
|
-
const out = new Uint8Array(clean.length / 2);
|
|
87
|
-
for (let i = 0; i < out.length; i++) {
|
|
88
|
-
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
89
|
-
}
|
|
90
|
-
return out;
|
|
91
|
-
}
|
|
92
49
|
function bytesToHex(bytes) {
|
|
93
50
|
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
94
51
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
uint64BE(params.stateVersion), sep,
|
|
107
|
-
uint64BE(params.keyEpoch), sep,
|
|
108
|
-
encoder.encode(membershipBlock), sep,
|
|
109
|
-
encoder.encode(policyBlock), sep,
|
|
110
|
-
prevBytes,
|
|
111
|
-
];
|
|
112
|
-
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
113
|
-
const data = new Uint8Array(total);
|
|
114
|
-
let offset = 0;
|
|
115
|
-
for (const chunk of chunks) {
|
|
116
|
-
data.set(chunk, offset);
|
|
117
|
-
offset += chunk.length;
|
|
52
|
+
function exactArrayBuffer(bytes) {
|
|
53
|
+
return bytes.slice().buffer;
|
|
54
|
+
}
|
|
55
|
+
function attachGatewayProximity(message, source) {
|
|
56
|
+
if (isJsonObject(source.proximity)) {
|
|
57
|
+
message.proximity = { ...source.proximity };
|
|
58
|
+
}
|
|
59
|
+
for (const key of ['same_device', 'same_network', 'same_egress_ip']) {
|
|
60
|
+
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
|
61
|
+
message[key] = source[key];
|
|
62
|
+
}
|
|
118
63
|
}
|
|
119
|
-
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
120
|
-
return bytesToHex(new Uint8Array(digest));
|
|
121
64
|
}
|
|
122
|
-
/** 内部专用方法(禁止用户直接调用) */
|
|
123
|
-
const INTERNAL_ONLY_METHODS = new Set([
|
|
124
|
-
'auth.login1',
|
|
125
|
-
'auth.aid_login1',
|
|
126
|
-
'auth.login2',
|
|
127
|
-
'auth.aid_login2',
|
|
128
|
-
'auth.connect',
|
|
129
|
-
'auth.refresh_token',
|
|
130
|
-
'initialize',
|
|
131
|
-
]);
|
|
132
|
-
/** 已移除的旧版 E2EE RPC。 */
|
|
133
|
-
const REMOVED_E2EE_METHODS = new Set([
|
|
134
|
-
'group.rotate_epoch',
|
|
135
|
-
]);
|
|
136
|
-
/** 需要客户端签名的关键方法 */
|
|
137
|
-
const SIGNED_METHODS = new Set([
|
|
138
|
-
'message.send',
|
|
139
|
-
'message.v2.put_peer_pk', 'message.v2.bootstrap',
|
|
140
|
-
'message.v2.group_bootstrap', 'message.v2.pull',
|
|
141
|
-
'message.v2.ack',
|
|
142
|
-
'group.send',
|
|
143
|
-
'group.v2.put_group_pk', 'group.v2.bootstrap',
|
|
144
|
-
'group.v2.send', 'group.v2.pull', 'group.v2.ack',
|
|
145
|
-
'group.v2.propose_state', 'group.v2.confirm_state',
|
|
146
|
-
'group.v2.get_proposal',
|
|
147
|
-
'group.kick', 'group.add_member',
|
|
148
|
-
'group.leave', 'group.remove_member', 'group.update_rules',
|
|
149
|
-
'group.update', 'group.update_announcement',
|
|
150
|
-
'group.update_join_requirements', 'group.set_role',
|
|
151
|
-
'group.transfer_owner', 'group.review_join_request',
|
|
152
|
-
'group.batch_review_join_request',
|
|
153
|
-
'group.request_join', 'group.use_invite_code',
|
|
154
|
-
'group.thought.put',
|
|
155
|
-
'message.thought.put',
|
|
156
|
-
'group.set_settings',
|
|
157
|
-
'group.resources.put', 'group.resources.update',
|
|
158
|
-
'group.resources.delete', 'group.resources.request_add',
|
|
159
|
-
'group.resources.direct_add', 'group.resources.approve_request',
|
|
160
|
-
'group.resources.reject_request',
|
|
161
|
-
'group.commit_state',
|
|
162
|
-
'group.ban', 'group.unban',
|
|
163
|
-
'group.dissolve', 'group.suspend', 'group.resume',
|
|
164
|
-
]);
|
|
165
65
|
const DEFAULT_SESSION_OPTIONS = {
|
|
166
66
|
auto_reconnect: true,
|
|
167
67
|
heartbeat_interval: 30.0,
|
|
@@ -178,31 +78,9 @@ const DEFAULT_SESSION_OPTIONS = {
|
|
|
178
78
|
http: 30.0,
|
|
179
79
|
},
|
|
180
80
|
};
|
|
181
|
-
const PUBLIC_CONNECTION_OPTION_KEYS = new Set([
|
|
182
|
-
'auto_reconnect',
|
|
183
|
-
'connect_timeout',
|
|
184
|
-
'retry_initial_delay',
|
|
185
|
-
'retry_max_delay',
|
|
186
|
-
'retry_max_attempts',
|
|
187
|
-
'heartbeat_interval',
|
|
188
|
-
'call_timeout',
|
|
189
|
-
'connection_kind',
|
|
190
|
-
'short_ttl_ms',
|
|
191
|
-
'delivery_mode',
|
|
192
|
-
'extra_info',
|
|
193
|
-
'background_sync',
|
|
194
|
-
]);
|
|
195
|
-
const PROTECTED_HEADERS_METHODS = new Set([
|
|
196
|
-
'message.send',
|
|
197
|
-
'group.send',
|
|
198
|
-
'message.thought.put',
|
|
199
|
-
'group.thought.put',
|
|
200
|
-
]);
|
|
201
81
|
const RECONNECT_MIN_BASE_DELAY_SECONDS = 1.0;
|
|
202
82
|
const RECONNECT_MAX_BASE_DELAY_SECONDS = 64.0;
|
|
203
83
|
const TOKEN_REFRESH_CHECK_INTERVAL_MS = 30_000;
|
|
204
|
-
const PUSHED_SEQS_LIMIT = 50_000;
|
|
205
|
-
const PENDING_ORDERED_LIMIT = 50_000;
|
|
206
84
|
// 心跳间隔下/上限(秒)。0 = 关闭心跳;负值视为 0;其余值 clamp 到 [10, 600]。
|
|
207
85
|
// 服务端通过 hello.heartbeat_interval 与 meta.ping pong 中的同名字段下发。
|
|
208
86
|
const HEARTBEAT_MIN_INTERVAL_SECONDS = 10;
|
|
@@ -229,6 +107,35 @@ const NON_IDEMPOTENT_METHODS = new Set([
|
|
|
229
107
|
'message.thought.put', 'group.thought.put',
|
|
230
108
|
'group.add_member',
|
|
231
109
|
]);
|
|
110
|
+
/** 需要客户端签名的关键方法。运行时逻辑已迁入 RpcPipeline,此处保留给源码审计测试。 */
|
|
111
|
+
const SIGNED_METHODS = new Set([
|
|
112
|
+
'message.send',
|
|
113
|
+
'message.v2.put_peer_pk', 'message.v2.bootstrap',
|
|
114
|
+
'message.v2.group_bootstrap', 'message.v2.pull',
|
|
115
|
+
'message.v2.ack',
|
|
116
|
+
'group.send',
|
|
117
|
+
'group.v2.put_group_pk', 'group.v2.bootstrap',
|
|
118
|
+
'group.v2.send', 'group.v2.pull', 'group.v2.ack',
|
|
119
|
+
'group.v2.propose_state', 'group.v2.confirm_state',
|
|
120
|
+
'group.v2.get_proposal',
|
|
121
|
+
'group.kick', 'group.add_member',
|
|
122
|
+
'group.leave', 'group.remove_member', 'group.update_rules',
|
|
123
|
+
'group.update', 'group.update_announcement',
|
|
124
|
+
'group.update_join_requirements', 'group.set_role',
|
|
125
|
+
'group.transfer_owner', 'group.review_join_request',
|
|
126
|
+
'group.batch_review_join_request',
|
|
127
|
+
'group.request_join', 'group.use_invite_code',
|
|
128
|
+
'group.thought.put',
|
|
129
|
+
'message.thought.put',
|
|
130
|
+
'group.set_settings',
|
|
131
|
+
'group.resources.put', 'group.resources.update',
|
|
132
|
+
'group.resources.delete', 'group.resources.request_add',
|
|
133
|
+
'group.resources.direct_add', 'group.resources.approve_request',
|
|
134
|
+
'group.resources.reject_request',
|
|
135
|
+
'group.commit_state',
|
|
136
|
+
'group.ban', 'group.unban',
|
|
137
|
+
'group.dissolve', 'group.suspend', 'group.resume',
|
|
138
|
+
]);
|
|
232
139
|
function clampReconnectDelaySeconds(value, fallback, upper = RECONNECT_MAX_BASE_DELAY_SECONDS) {
|
|
233
140
|
const parsed = Number(value);
|
|
234
141
|
const seconds = Number.isFinite(parsed) ? parsed : fallback;
|
|
@@ -328,21 +235,22 @@ function createAgentMdManagerForRuntime(opts) {
|
|
|
328
235
|
opts.identity.set(await auth.loadIdentityOrNone(target) ?? fallbackIdentity);
|
|
329
236
|
return token;
|
|
330
237
|
},
|
|
331
|
-
peerResolver: async (aid) => {
|
|
238
|
+
peerResolver: async (aid, certFingerprint) => {
|
|
332
239
|
const target = String(aid ?? '').trim();
|
|
240
|
+
const expectedFp = String(certFingerprint ?? '').trim().toLowerCase();
|
|
333
241
|
const current = opts.currentAid();
|
|
334
|
-
if (current?.aid === target)
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
catch { /* best effort */ }
|
|
242
|
+
if (current?.aid === target) {
|
|
243
|
+
if (!expectedFp || await certMatchesFingerprint(current.certPem, expectedFp))
|
|
244
|
+
return current;
|
|
245
|
+
throw new StateError(`current AID certificate fingerprint mismatch for ${target}`);
|
|
246
|
+
}
|
|
247
|
+
if (!opts.gateway.get()) {
|
|
248
|
+
try {
|
|
249
|
+
opts.gateway.set(await opts.gateway.resolve(target));
|
|
343
250
|
}
|
|
344
|
-
|
|
251
|
+
catch { /* best effort */ }
|
|
345
252
|
}
|
|
253
|
+
const certPem = String(await opts.fetchPeerCert(target, expectedFp || undefined) ?? '').trim();
|
|
346
254
|
if (!certPem)
|
|
347
255
|
throw new NotFoundError(`certificate not found for aid: ${target}`);
|
|
348
256
|
return await AID.create({
|
|
@@ -374,80 +282,6 @@ function resolvePeerGatewayUrl(localGatewayUrl, peerAid) {
|
|
|
374
282
|
return localGatewayUrl;
|
|
375
283
|
return localGatewayUrl.replace(`gateway.${localIssuer}`, `gateway.${peerIssuer}`);
|
|
376
284
|
}
|
|
377
|
-
function isGroupServiceAid(value) {
|
|
378
|
-
const text = String(value ?? '').trim();
|
|
379
|
-
if (!text.includes('.'))
|
|
380
|
-
return false;
|
|
381
|
-
const [name, ...issuerParts] = text.split('.');
|
|
382
|
-
return name === 'group' && issuerParts.join('.').length > 0;
|
|
383
|
-
}
|
|
384
|
-
function normalizeV2WrapPolicy(raw) {
|
|
385
|
-
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
386
|
-
return undefined;
|
|
387
|
-
const obj = raw;
|
|
388
|
-
let protocol = String(obj.protocol ?? '').trim().toUpperCase();
|
|
389
|
-
let scope = String(obj.scope ?? '').trim().toLowerCase();
|
|
390
|
-
if (scope !== 'aid' && scope !== 'device') {
|
|
391
|
-
if (obj.per_aid_wrap === true)
|
|
392
|
-
scope = 'aid';
|
|
393
|
-
else if (obj.per_device_wrap === true)
|
|
394
|
-
scope = 'device';
|
|
395
|
-
else
|
|
396
|
-
scope = '';
|
|
397
|
-
}
|
|
398
|
-
if (protocol !== '1DH' && protocol !== '3DH')
|
|
399
|
-
protocol = '';
|
|
400
|
-
if (scope === 'aid')
|
|
401
|
-
protocol = '1DH';
|
|
402
|
-
if (!protocol && !scope)
|
|
403
|
-
return undefined;
|
|
404
|
-
return {
|
|
405
|
-
protocol: protocol ? protocol : undefined,
|
|
406
|
-
scope: scope ? scope : undefined,
|
|
407
|
-
};
|
|
408
|
-
}
|
|
409
|
-
function v2WrapCapabilities() {
|
|
410
|
-
return {
|
|
411
|
-
version: 'v2.1',
|
|
412
|
-
protocols: ['1DH', '3DH'],
|
|
413
|
-
scopes: ['aid', 'device'],
|
|
414
|
-
per_aid_wrap: true,
|
|
415
|
-
per_device_wrap: true,
|
|
416
|
-
};
|
|
417
|
-
}
|
|
418
|
-
function applyV2WrapPolicyToTargets(targets, policy) {
|
|
419
|
-
if (!policy)
|
|
420
|
-
return targets;
|
|
421
|
-
const normalized = targets.map((target) => {
|
|
422
|
-
const row = { ...target };
|
|
423
|
-
if (policy.protocol === '1DH') {
|
|
424
|
-
row.keySource = 'aid_master';
|
|
425
|
-
row.spkPkDer = undefined;
|
|
426
|
-
row.spkId = '';
|
|
427
|
-
}
|
|
428
|
-
return row;
|
|
429
|
-
});
|
|
430
|
-
if (policy.scope !== 'aid')
|
|
431
|
-
return normalized;
|
|
432
|
-
const collapsed = new Map();
|
|
433
|
-
for (const target of normalized) {
|
|
434
|
-
const key = `${target.aid}\u0000${target.role}`;
|
|
435
|
-
if (!collapsed.has(key)) {
|
|
436
|
-
collapsed.set(key, { ...target, deviceId: '' });
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
return Array.from(collapsed.values());
|
|
440
|
-
}
|
|
441
|
-
/** 32 字节左侧零填充(用于 P-256 私钥 scalar 规范化) */
|
|
442
|
-
function _v2LeftPad32(b) {
|
|
443
|
-
if (b.length === 32)
|
|
444
|
-
return b;
|
|
445
|
-
if (b.length > 32)
|
|
446
|
-
return b.subarray(b.length - 32);
|
|
447
|
-
const out = new Uint8Array(32);
|
|
448
|
-
out.set(b, 32 - b.length);
|
|
449
|
-
return out;
|
|
450
|
-
}
|
|
451
285
|
/** Base64 (标准) → Uint8Array */
|
|
452
286
|
function _v2B64ToBytes(s) {
|
|
453
287
|
const bin = atob(s);
|
|
@@ -481,24 +315,6 @@ function _v2ConcatBytes(...parts) {
|
|
|
481
315
|
}
|
|
482
316
|
return out;
|
|
483
317
|
}
|
|
484
|
-
function _v2LengthPrefixedTextKey(...parts) {
|
|
485
|
-
const enc = new TextEncoder();
|
|
486
|
-
return parts.map((part) => `${enc.encode(part).length}:${part};`).join('');
|
|
487
|
-
}
|
|
488
|
-
function _v2LengthPrefixedBytes(...parts) {
|
|
489
|
-
const enc = new TextEncoder();
|
|
490
|
-
const framed = [];
|
|
491
|
-
for (const part of parts) {
|
|
492
|
-
framed.push(enc.encode(`${part.length}:`), part, enc.encode(';'));
|
|
493
|
-
}
|
|
494
|
-
return _v2ConcatBytes(...framed);
|
|
495
|
-
}
|
|
496
|
-
/** Base64URL → Uint8Array(兼容缺失 padding) */
|
|
497
|
-
function _v2B64uToBytes(s) {
|
|
498
|
-
const std = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
499
|
-
const pad = std.length % 4 === 0 ? '' : '='.repeat(4 - (std.length % 4));
|
|
500
|
-
return _v2B64ToBytes(std + pad);
|
|
501
|
-
}
|
|
502
318
|
function formatCaughtError(error) {
|
|
503
319
|
return error instanceof Error ? error : String(error);
|
|
504
320
|
}
|
|
@@ -564,85 +380,6 @@ function extractV2EnvelopeFromSource(source) {
|
|
|
564
380
|
}
|
|
565
381
|
return null;
|
|
566
382
|
}
|
|
567
|
-
function truthyBool(value) {
|
|
568
|
-
if (value === true || value === 1)
|
|
569
|
-
return true;
|
|
570
|
-
if (typeof value === 'string') {
|
|
571
|
-
const normalized = value.trim().toLowerCase();
|
|
572
|
-
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
|
573
|
-
}
|
|
574
|
-
return false;
|
|
575
|
-
}
|
|
576
|
-
function isEncryptedEnvelopePayload(payload) {
|
|
577
|
-
if (!isJsonObject(payload))
|
|
578
|
-
return false;
|
|
579
|
-
const payloadType = String(payload.type ?? '').trim();
|
|
580
|
-
if (payloadType.startsWith('e2ee.'))
|
|
581
|
-
return true;
|
|
582
|
-
if (!String(payload.ciphertext ?? '').trim())
|
|
583
|
-
return false;
|
|
584
|
-
return payload.nonce !== undefined
|
|
585
|
-
|| payload.tag !== undefined
|
|
586
|
-
|| payload.recipient !== undefined
|
|
587
|
-
|| payload.recipients !== undefined
|
|
588
|
-
|| payload.wrapped_key !== undefined
|
|
589
|
-
|| payload.recipients_digest !== undefined;
|
|
590
|
-
}
|
|
591
|
-
function encryptedPushEnvelope(msg) {
|
|
592
|
-
if (isEncryptedEnvelopePayload(msg.payload))
|
|
593
|
-
return msg.payload;
|
|
594
|
-
if (typeof msg.envelope_json === 'string' && msg.envelope_json.trim()) {
|
|
595
|
-
try {
|
|
596
|
-
const parsed = JSON.parse(msg.envelope_json);
|
|
597
|
-
if (isEncryptedEnvelopePayload(parsed))
|
|
598
|
-
return parsed;
|
|
599
|
-
}
|
|
600
|
-
catch {
|
|
601
|
-
return null;
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
return null;
|
|
605
|
-
}
|
|
606
|
-
function isEncryptedPushMessage(msg) {
|
|
607
|
-
if (truthyBool(msg.encrypted))
|
|
608
|
-
return true;
|
|
609
|
-
return encryptedPushEnvelope(msg) !== null;
|
|
610
|
-
}
|
|
611
|
-
function isV2EncryptedEnvelopePayload(envelope) {
|
|
612
|
-
if (!envelope)
|
|
613
|
-
return false;
|
|
614
|
-
const payloadType = String(envelope.type ?? '').trim();
|
|
615
|
-
if (payloadType === 'e2ee.p2p_encrypted' || payloadType === 'e2ee.group_encrypted')
|
|
616
|
-
return true;
|
|
617
|
-
return String(envelope.version ?? '').trim().toLowerCase() === 'v2' && payloadType.startsWith('e2ee.');
|
|
618
|
-
}
|
|
619
|
-
function safeUndecryptablePushEvent(msg, group) {
|
|
620
|
-
const event = {
|
|
621
|
-
message_id: msg.message_id ?? null,
|
|
622
|
-
from: msg.from ?? null,
|
|
623
|
-
seq: msg.seq ?? null,
|
|
624
|
-
timestamp: msg.timestamp ?? msg.t_server ?? null,
|
|
625
|
-
device_id: msg.device_id ?? null,
|
|
626
|
-
slot_id: msg.slot_id ?? null,
|
|
627
|
-
_decrypt_error: 'encrypted push payload is not decryptable on raw push path',
|
|
628
|
-
_decrypt_stage: 'push_envelope',
|
|
629
|
-
};
|
|
630
|
-
if (group) {
|
|
631
|
-
event.group_id = msg.group_id ?? null;
|
|
632
|
-
}
|
|
633
|
-
else {
|
|
634
|
-
event.to = msg.to ?? null;
|
|
635
|
-
}
|
|
636
|
-
const envelope = encryptedPushEnvelope(msg);
|
|
637
|
-
if (envelope) {
|
|
638
|
-
event._envelope_type = String(envelope.type ?? '');
|
|
639
|
-
event._suite = String(envelope.suite ?? '');
|
|
640
|
-
if (isV2EncryptedEnvelopePayload(envelope)) {
|
|
641
|
-
attachV2EnvelopeMetadata(event, v2E2eeMeta(envelope));
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
return event;
|
|
645
|
-
}
|
|
646
383
|
function metadataWithoutAuth(value) {
|
|
647
384
|
if (!isJsonObject(value))
|
|
648
385
|
return null;
|
|
@@ -740,15 +477,13 @@ export class AUNClient {
|
|
|
740
477
|
_v2Session;
|
|
741
478
|
_v2KeyStore;
|
|
742
479
|
_v2SessionInitInFlight = null;
|
|
480
|
+
_v2RuntimeGeneration = 0;
|
|
743
481
|
_v2BootstrapCache = new Map();
|
|
744
482
|
_v2SenderIKPending = new Map();
|
|
745
483
|
_v2SenderIKFetching = new Set();
|
|
746
484
|
static V2_BOOTSTRAP_TTL_MS = 60 * 60 * 1000;
|
|
747
|
-
static V2_RETRYABLE_CODES = new Set([-33011, -33012, -33050, -33052, -33054]);
|
|
748
485
|
/** V2 state 签名验证缓存:cacheKey(hex) → expiry_unix_ms */
|
|
749
486
|
_v2SigCache = new Map();
|
|
750
|
-
static _V2_SIG_CACHE_TTL = 60 * 60 * 1000;
|
|
751
|
-
static _V2_SIG_CACHE_MAX = 16384;
|
|
752
487
|
/** V2 state chain 本地记录:group_id → [state_version, chain_hash] */
|
|
753
488
|
_v2StateChains = new Map();
|
|
754
489
|
/** 群安全等级追踪(变化时发布 group.v2.security_level 事件) */
|
|
@@ -773,11 +508,16 @@ export class AUNClient {
|
|
|
773
508
|
_pendingOrderedMsgs = new Map();
|
|
774
509
|
/** Lazy group sync:首次发送群消息前自动拉取历史 */
|
|
775
510
|
_groupSynced = new Set();
|
|
511
|
+
/** 在线未读 hint 队列:同一 group 只保留最后一条,延迟 drain 降低登录瞬时拉取压力。 */
|
|
512
|
+
_onlineUnreadHintQueue = new Map();
|
|
513
|
+
_onlineUnreadHintTimer = null;
|
|
514
|
+
_onlineUnreadHintDrainActive = false;
|
|
515
|
+
_onlineUnreadHintInitialDelayMs = 750;
|
|
516
|
+
_onlineUnreadHintIntervalMs = 50;
|
|
776
517
|
/** gap fill 来源标记:true 表示当前正在补洞(pull 触发),false 表示非补洞 */
|
|
777
518
|
_gapFillActive = false;
|
|
778
519
|
// Pull Gate:序列化同一 key 的并发 pull 操作,防止重复拉取
|
|
779
520
|
_pullGates = new Map();
|
|
780
|
-
static _PULL_GATE_STALE_MS = 30000;
|
|
781
521
|
// 重连相关
|
|
782
522
|
_reconnectActive = false;
|
|
783
523
|
_reconnectAbort = null;
|
|
@@ -803,6 +543,14 @@ export class AUNClient {
|
|
|
803
543
|
_tokenStoreLog;
|
|
804
544
|
_logDiscovery;
|
|
805
545
|
_logEvents;
|
|
546
|
+
_runtime;
|
|
547
|
+
_identityRuntime;
|
|
548
|
+
_peerDirectory;
|
|
549
|
+
_lifecycle;
|
|
550
|
+
_rpcPipeline;
|
|
551
|
+
_delivery;
|
|
552
|
+
_v2E2EE;
|
|
553
|
+
_groupState;
|
|
806
554
|
constructor(aid) {
|
|
807
555
|
if (aid !== null && aid !== undefined && !isAIDObject(aid)) {
|
|
808
556
|
throw new ValidationError('AUNClient only accepts an AID object or no argument');
|
|
@@ -862,7 +610,7 @@ export class AUNClient {
|
|
|
862
610
|
},
|
|
863
611
|
auth: () => this._auth,
|
|
864
612
|
tokenStore: () => this._tokenStore,
|
|
865
|
-
fetchPeerCert: (target) => this._fetchPeerCert(target),
|
|
613
|
+
fetchPeerCert: (target, certFingerprint) => this._fetchPeerCert(target, certFingerprint || undefined),
|
|
866
614
|
});
|
|
867
615
|
this._transport = new RPCTransport({
|
|
868
616
|
eventDispatcher: this._dispatcher,
|
|
@@ -874,6 +622,14 @@ export class AUNClient {
|
|
|
874
622
|
this._clientLog.debug(`agent.md meta observer skipped: ${String(exc)}`);
|
|
875
623
|
});
|
|
876
624
|
});
|
|
625
|
+
this._runtime = new ClientRuntime(this);
|
|
626
|
+
this._identityRuntime = new IdentityRuntimeManager(this._runtime);
|
|
627
|
+
this._peerDirectory = new PeerDirectory(this._runtime);
|
|
628
|
+
this._lifecycle = new LifecycleController(this._runtime);
|
|
629
|
+
this._rpcPipeline = new RpcPipeline(this._runtime);
|
|
630
|
+
this._delivery = new MessageDeliveryEngine(this._runtime);
|
|
631
|
+
this._v2E2EE = new V2E2EECoordinator(this._runtime);
|
|
632
|
+
this._groupState = new GroupStateCoordinator(this._runtime);
|
|
877
633
|
if (inputAid) {
|
|
878
634
|
if (inputAid.isPrivateKeyValid()) {
|
|
879
635
|
this._currentAid = inputAid;
|
|
@@ -884,7 +640,7 @@ export class AUNClient {
|
|
|
884
640
|
cert: inputAid.certPem,
|
|
885
641
|
};
|
|
886
642
|
this._auth.setIdentity(this._identity);
|
|
887
|
-
this._state = '
|
|
643
|
+
this._state = 'standby';
|
|
888
644
|
}
|
|
889
645
|
}
|
|
890
646
|
// 注入 logger 到各子模块(构造时未传 logger,构造后通过 setLogger 注入)
|
|
@@ -947,9 +703,6 @@ export class AUNClient {
|
|
|
947
703
|
get aid() {
|
|
948
704
|
return this._aid;
|
|
949
705
|
}
|
|
950
|
-
async uploadAgentMd(content) {
|
|
951
|
-
return await this._agentMdManager.upload(content);
|
|
952
|
-
}
|
|
953
706
|
async _observeAgentMdFromEnvelope(envelope) {
|
|
954
707
|
await this._agentMdManager.observeEnvelope(envelope);
|
|
955
708
|
}
|
|
@@ -1005,7 +758,53 @@ export class AUNClient {
|
|
|
1005
758
|
get lastError() { return this._lastError; }
|
|
1006
759
|
/** 最近一次错误码(对齐 Python last_error_code) */
|
|
1007
760
|
get lastErrorCode() { return this._lastErrorCode; }
|
|
761
|
+
_v2SessionMatchesIdentity() {
|
|
762
|
+
if (!this._v2Session)
|
|
763
|
+
return false;
|
|
764
|
+
const session = this._v2Session;
|
|
765
|
+
if (session.currentIkPubDer === undefined)
|
|
766
|
+
return true;
|
|
767
|
+
return session.aid === this._aid && session.deviceId === this._deviceId;
|
|
768
|
+
}
|
|
769
|
+
_resetV2IdentityRuntime() {
|
|
770
|
+
this._v2RuntimeGeneration += 1;
|
|
771
|
+
const keyStore = this._v2KeyStore;
|
|
772
|
+
try {
|
|
773
|
+
keyStore?.close?.();
|
|
774
|
+
}
|
|
775
|
+
catch (exc) {
|
|
776
|
+
this._clientLog?.debug?.(`V2 keystore cleanup skipped: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
777
|
+
}
|
|
778
|
+
this._v2Session = undefined;
|
|
779
|
+
this._v2KeyStore = undefined;
|
|
780
|
+
this._v2SessionInitInFlight = null;
|
|
781
|
+
this._v2BootstrapCache.clear();
|
|
782
|
+
this._v2SenderIKPending.clear();
|
|
783
|
+
this._v2SenderIKFetching.clear();
|
|
784
|
+
this._v2SigCache.clear();
|
|
785
|
+
this._v2StateChains.clear();
|
|
786
|
+
this._v2GroupSecurityLevels.clear();
|
|
787
|
+
this._v2AutoProposeInflight.clear();
|
|
788
|
+
this._v2AutoProposePending.clear();
|
|
789
|
+
this._v2AutoProposeLastSnapshot.clear();
|
|
790
|
+
this._v2LazyProposeTriggered.clear();
|
|
791
|
+
this._v2PullInflight = false;
|
|
792
|
+
this._v2PullPending = false;
|
|
793
|
+
}
|
|
1008
794
|
_applyAidRuntimeContext(aid) {
|
|
795
|
+
const oldTransport = this._transport;
|
|
796
|
+
try {
|
|
797
|
+
const closeResult = oldTransport?.close?.();
|
|
798
|
+
if (closeResult && typeof closeResult.catch === 'function') {
|
|
799
|
+
void closeResult.catch((exc) => {
|
|
800
|
+
this._clientLog.debug(`old transport cleanup skipped: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
catch (exc) {
|
|
805
|
+
this._clientLog.debug(`old transport cleanup skipped: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
806
|
+
}
|
|
807
|
+
this._resetV2IdentityRuntime();
|
|
1009
808
|
const nextConfig = createConfig({
|
|
1010
809
|
aunPath: aid.aunPath,
|
|
1011
810
|
rootCaPem: aid.rootCaPath,
|
|
@@ -1055,7 +854,7 @@ export class AUNClient {
|
|
|
1055
854
|
},
|
|
1056
855
|
auth: () => this._auth,
|
|
1057
856
|
tokenStore: () => this._tokenStore,
|
|
1058
|
-
fetchPeerCert: (target) => this._fetchPeerCert(target),
|
|
857
|
+
fetchPeerCert: (target, certFingerprint) => this._fetchPeerCert(target, certFingerprint || undefined),
|
|
1059
858
|
});
|
|
1060
859
|
this._transport = new RPCTransport({
|
|
1061
860
|
eventDispatcher: this._dispatcher,
|
|
@@ -1078,25 +877,7 @@ export class AUNClient {
|
|
|
1078
877
|
}
|
|
1079
878
|
}
|
|
1080
879
|
loadIdentity(aid) {
|
|
1081
|
-
|
|
1082
|
-
throw new StateError('loadIdentity requires an AID with a valid private key');
|
|
1083
|
-
const publicState = this.state;
|
|
1084
|
-
if (publicState !== ConnectionState.NO_IDENTITY && publicState !== ConnectionState.CLOSED) {
|
|
1085
|
-
throw new StateError(`loadIdentity not allowed in state ${publicState}`);
|
|
1086
|
-
}
|
|
1087
|
-
this._applyAidRuntimeContext(aid);
|
|
1088
|
-
this._currentAid = aid;
|
|
1089
|
-
this._aid = aid.aid;
|
|
1090
|
-
this._identity = {
|
|
1091
|
-
aid: aid.aid,
|
|
1092
|
-
private_key_pem: aid.privateKeyPem,
|
|
1093
|
-
public_key_der_b64: aid.publicKey,
|
|
1094
|
-
cert: aid.certPem,
|
|
1095
|
-
};
|
|
1096
|
-
// 注入内存私钥到 AuthFlow,禁止 AuthFlow 内部再走 keystore 解密
|
|
1097
|
-
this._auth.setIdentity(this._identity);
|
|
1098
|
-
this._state = 'disconnected';
|
|
1099
|
-
this._closing = false;
|
|
880
|
+
this._identityRuntime.loadIdentity(aid);
|
|
1100
881
|
}
|
|
1101
882
|
setProtectedHeaders(headers) {
|
|
1102
883
|
if (!headers) {
|
|
@@ -1115,33 +896,16 @@ export class AUNClient {
|
|
|
1115
896
|
return this._instanceProtectedHeaders ? { ...this._instanceProtectedHeaders } : null;
|
|
1116
897
|
}
|
|
1117
898
|
cachePeer(aid) {
|
|
1118
|
-
|
|
1119
|
-
throw new StateError('cachePeer requires a loaded identity');
|
|
1120
|
-
if (!aid.isCertValid())
|
|
1121
|
-
throw new ValidationError('cachePeer requires an AID with a valid certificate');
|
|
1122
|
-
this._peerCache.set(aid.aid, aid);
|
|
1123
|
-
return aid;
|
|
899
|
+
return this._peerDirectory.cachePeer(aid);
|
|
1124
900
|
}
|
|
1125
901
|
getPeer(aid) {
|
|
1126
|
-
|
|
1127
|
-
throw new StateError('getPeer requires a loaded identity');
|
|
1128
|
-
return this._peerCache.get(String(aid ?? '').trim()) ?? null;
|
|
902
|
+
return this._peerDirectory.getPeer(aid);
|
|
1129
903
|
}
|
|
1130
904
|
async lookupPeer(aid) {
|
|
1131
|
-
|
|
1132
|
-
throw new StateError('lookupPeer requires a loaded identity');
|
|
1133
|
-
const target = String(aid ?? '').trim();
|
|
1134
|
-
if (!target)
|
|
1135
|
-
throw new ValidationError('lookupPeer requires non-empty aid');
|
|
1136
|
-
const cached = this._peerCache.get(target);
|
|
1137
|
-
if (cached)
|
|
1138
|
-
return cached;
|
|
1139
|
-
throw new NotFoundError(`peer not found in cache: ${target}`);
|
|
905
|
+
return this._peerDirectory.lookupPeer(aid);
|
|
1140
906
|
}
|
|
1141
907
|
peers() {
|
|
1142
|
-
|
|
1143
|
-
throw new StateError('peers requires a loaded identity');
|
|
1144
|
-
return [...this._peerCache.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, v]) => v);
|
|
908
|
+
return this._peerDirectory.peers();
|
|
1145
909
|
}
|
|
1146
910
|
get gatewayUrl() {
|
|
1147
911
|
return this._gatewayUrl;
|
|
@@ -1156,130 +920,28 @@ export class AUNClient {
|
|
|
1156
920
|
// ── 生命周期 ──────────────────────────────────────
|
|
1157
921
|
/** 仅认证当前身份,获取/刷新 token,但不建立长连接。 */
|
|
1158
922
|
async authenticate(options = {}) {
|
|
1159
|
-
|
|
1160
|
-
const target = this._currentAid?.aid ?? this._aid ?? '';
|
|
1161
|
-
if (!target || !this._currentAid?.isPrivateKeyValid()) {
|
|
1162
|
-
throw new StateError('authenticate requires a loaded AID with a valid private key');
|
|
1163
|
-
}
|
|
1164
|
-
const publicState = this.state;
|
|
1165
|
-
if (publicState !== ConnectionState.STANDBY) {
|
|
1166
|
-
throw new StateError(`authenticate not allowed in state ${publicState}`);
|
|
1167
|
-
}
|
|
1168
|
-
if ('aid' in options || 'access_token' in options || 'token' in options || 'kite_token' in options) {
|
|
1169
|
-
throw new ValidationError('authenticate options must not include aid or token fields; load an AID object first');
|
|
1170
|
-
}
|
|
1171
|
-
this._state = 'connecting';
|
|
1172
|
-
try {
|
|
1173
|
-
const gateway = String(options.gateway ?? this._gatewayUrl ?? await this._resolveGatewayForAid(target)).trim();
|
|
1174
|
-
const result = await this._auth.authenticate(gateway, target);
|
|
1175
|
-
this._gatewayUrl = String(result.gateway ?? gateway);
|
|
1176
|
-
this._identity = await this._auth.loadIdentityOrNone(target);
|
|
1177
|
-
this._state = 'authenticated';
|
|
1178
|
-
this._clientLog.debug(`authenticate exit: elapsed=${Date.now() - tStart}ms aid=${target}`);
|
|
1179
|
-
return result;
|
|
1180
|
-
}
|
|
1181
|
-
catch (err) {
|
|
1182
|
-
this._state = 'disconnected';
|
|
1183
|
-
this._clientLog.debug(`authenticate exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
1184
|
-
throw err;
|
|
1185
|
-
}
|
|
923
|
+
return this._lifecycle.authenticate(options);
|
|
1186
924
|
}
|
|
1187
925
|
/** 连接到 Gateway;身份来自构造函数或 loadIdentity(aid),认证由 SDK 内部自动完成。 */
|
|
1188
926
|
async connect(opts) {
|
|
1189
|
-
|
|
1190
|
-
if (opts !== undefined && opts !== null && typeof opts === 'object') {
|
|
1191
|
-
const raw = opts;
|
|
1192
|
-
const invalid = Object.keys(raw).filter((key) => !PUBLIC_CONNECTION_OPTION_KEYS.has(key)).sort();
|
|
1193
|
-
if (invalid.length > 0) {
|
|
1194
|
-
throw new ValidationError(`connect options contain unsupported field(s): ${invalid.join(', ')}`);
|
|
1195
|
-
}
|
|
1196
|
-
}
|
|
1197
|
-
const target = this._currentAid?.aid ?? this._aid ?? '';
|
|
1198
|
-
if (!target || !this._currentAid?.isPrivateKeyValid()) {
|
|
1199
|
-
throw new StateError('connect requires a loaded AID with a valid private key');
|
|
1200
|
-
}
|
|
1201
|
-
const options = {};
|
|
1202
|
-
if (opts?.auto_reconnect !== undefined)
|
|
1203
|
-
options.auto_reconnect = opts.auto_reconnect;
|
|
1204
|
-
if (opts?.heartbeat_interval !== undefined)
|
|
1205
|
-
options.heartbeat_interval = opts.heartbeat_interval;
|
|
1206
|
-
if (opts?.connect_timeout !== undefined || opts?.call_timeout !== undefined) {
|
|
1207
|
-
options.timeouts = {
|
|
1208
|
-
...(opts.connect_timeout !== undefined ? { connect: opts.connect_timeout } : {}),
|
|
1209
|
-
...(opts.call_timeout !== undefined ? { call: opts.call_timeout } : {}),
|
|
1210
|
-
};
|
|
1211
|
-
}
|
|
1212
|
-
if (opts?.retry_initial_delay !== undefined || opts?.retry_max_delay !== undefined || opts?.retry_max_attempts !== undefined) {
|
|
1213
|
-
options.retry = {
|
|
1214
|
-
initial_delay: opts.retry_initial_delay ?? 1,
|
|
1215
|
-
max_delay: opts.retry_max_delay ?? 64,
|
|
1216
|
-
max_attempts: opts.retry_max_attempts ?? 0,
|
|
1217
|
-
};
|
|
1218
|
-
}
|
|
1219
|
-
if (opts?.connection_kind !== undefined)
|
|
1220
|
-
options.connection_kind = opts.connection_kind;
|
|
1221
|
-
if (opts?.short_ttl_ms !== undefined)
|
|
1222
|
-
options.short_ttl_ms = opts.short_ttl_ms;
|
|
1223
|
-
if (opts?.delivery_mode !== undefined)
|
|
1224
|
-
options.delivery_mode = opts.delivery_mode;
|
|
1225
|
-
if (opts?.extra_info !== undefined)
|
|
1226
|
-
options.extra_info = opts.extra_info;
|
|
1227
|
-
if (opts?.background_sync !== undefined)
|
|
1228
|
-
options.background_sync = opts.background_sync;
|
|
1229
|
-
this._clientLog.debug(`connect enter: state=${this._state} aid=${this._aid ?? '-'}`);
|
|
1230
|
-
const publicState = this.state;
|
|
1231
|
-
const allowed = new Set([
|
|
1232
|
-
ConnectionState.STANDBY,
|
|
1233
|
-
ConnectionState.AUTHENTICATED,
|
|
1234
|
-
ConnectionState.RETRY_BACKOFF,
|
|
1235
|
-
ConnectionState.CONNECTION_FAILED,
|
|
1236
|
-
]);
|
|
1237
|
-
if (!allowed.has(publicState)) {
|
|
1238
|
-
this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=invalid_state state=${this._state}`);
|
|
1239
|
-
throw new StateError(`connect not allowed in state ${publicState}`);
|
|
1240
|
-
}
|
|
1241
|
-
// gateway 来自 authenticate() 缓存的 this._gatewayUrl;未认证则自动 authenticate()
|
|
1242
|
-
if (!this._gatewayUrl) {
|
|
1243
|
-
await this.authenticate();
|
|
1244
|
-
}
|
|
1245
|
-
this._state = 'connecting';
|
|
1246
|
-
const gateway = String(this._gatewayUrl ?? '').trim();
|
|
1247
|
-
const params = { ...options, gateway };
|
|
1248
|
-
const normalized = this._normalizeConnectParams(params);
|
|
1249
|
-
this._sessionParams = normalized;
|
|
1250
|
-
this._sessionOptions = this._buildSessionOptions(normalized);
|
|
1251
|
-
this._transport.setTimeout(this._sessionOptions.timeouts.call);
|
|
1252
|
-
this._closing = false;
|
|
1253
|
-
const gateways = this._resolveGateways(normalized);
|
|
1254
|
-
let lastErr = null;
|
|
1255
|
-
for (const gw of gateways) {
|
|
1256
|
-
try {
|
|
1257
|
-
const gwParams = { ...normalized, gateway: gw };
|
|
1258
|
-
await this._connectOnce(gwParams, true);
|
|
1259
|
-
this._clientLog.debug(`connect exit: elapsed=${Date.now() - tStart}ms state=${this._state}`);
|
|
1260
|
-
return;
|
|
1261
|
-
}
|
|
1262
|
-
catch (err) {
|
|
1263
|
-
lastErr = err;
|
|
1264
|
-
if (gateways.length > 1) {
|
|
1265
|
-
this._clientLog.warn(`connect: gateway ${gw} failed, trying next: ${err instanceof Error ? err.message : String(err)}`);
|
|
1266
|
-
}
|
|
1267
|
-
if (this._state === 'connecting' || this._state === 'authenticating') {
|
|
1268
|
-
this._state = 'connecting';
|
|
1269
|
-
}
|
|
1270
|
-
}
|
|
1271
|
-
}
|
|
1272
|
-
if (this._state === 'connecting' || this._state === 'authenticating') {
|
|
1273
|
-
this._state = 'terminal_failed';
|
|
1274
|
-
}
|
|
1275
|
-
this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
|
|
1276
|
-
throw lastErr;
|
|
927
|
+
return this._lifecycle.connect(opts);
|
|
1277
928
|
}
|
|
1278
929
|
/** 断开连接但保留本地状态,可再次 connect */
|
|
1279
930
|
async disconnect() {
|
|
1280
931
|
const tStart = Date.now();
|
|
1281
932
|
this._clientLog.debug(`disconnect enter: state=${this._state}`);
|
|
1282
|
-
if (this.
|
|
933
|
+
if (this._closing) {
|
|
934
|
+
this._clientLog.debug(`disconnect exit: elapsed=${Date.now() - tStart}ms reason=closing`);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
if (![
|
|
938
|
+
ConnectionState.AUTHENTICATED,
|
|
939
|
+
ConnectionState.CONNECTING,
|
|
940
|
+
ConnectionState.READY,
|
|
941
|
+
ConnectionState.RETRY_BACKOFF,
|
|
942
|
+
ConnectionState.RECONNECTING,
|
|
943
|
+
ConnectionState.CONNECTION_FAILED,
|
|
944
|
+
].includes(this.state)) {
|
|
1283
945
|
this._clientLog.debug(`disconnect exit: elapsed=${Date.now() - tStart}ms reason=not_connected`);
|
|
1284
946
|
return;
|
|
1285
947
|
}
|
|
@@ -1291,7 +953,7 @@ export class AUNClient {
|
|
|
1291
953
|
this._reconnectActive = false;
|
|
1292
954
|
}
|
|
1293
955
|
await this._transport.close();
|
|
1294
|
-
this._state = '
|
|
956
|
+
this._state = 'standby';
|
|
1295
957
|
await this._dispatcher.publish('state_change', { state: this._publicState(this._state) });
|
|
1296
958
|
this._clientLog.debug(`disconnect exit: elapsed=${Date.now() - tStart}ms`);
|
|
1297
959
|
}
|
|
@@ -1348,49 +1010,7 @@ export class AUNClient {
|
|
|
1348
1010
|
}
|
|
1349
1011
|
}
|
|
1350
1012
|
async _callImpl(method, params) {
|
|
1351
|
-
|
|
1352
|
-
throw new ConnectionError('client is not connected');
|
|
1353
|
-
}
|
|
1354
|
-
if (INTERNAL_ONLY_METHODS.has(method)) {
|
|
1355
|
-
throw new PermissionError(`method is internal_only: ${method}`);
|
|
1356
|
-
}
|
|
1357
|
-
if (method.startsWith('message.e2ee.') || method.startsWith('group.e2ee.') || REMOVED_E2EE_METHODS.has(method)) {
|
|
1358
|
-
throw new PermissionError(`legacy E2EE method is removed in this SDK: ${method}`);
|
|
1359
|
-
}
|
|
1360
|
-
const p = { ...(params ?? {}) };
|
|
1361
|
-
if (this._instanceProtectedHeaders && PROTECTED_HEADERS_METHODS.has(method)) {
|
|
1362
|
-
const existing = isJsonObject(p.protected_headers) ? p.protected_headers : {};
|
|
1363
|
-
p.protected_headers = { ...this._instanceProtectedHeaders, ...existing };
|
|
1364
|
-
}
|
|
1365
|
-
if (method === 'message.send' || method === 'group.send') {
|
|
1366
|
-
this._normalizeOutboundMessagePayload(p, method);
|
|
1367
|
-
}
|
|
1368
|
-
this._validateOutboundCall(method, p);
|
|
1369
|
-
this._injectMessageCursorContext(method, p);
|
|
1370
|
-
if (method.startsWith('group.')
|
|
1371
|
-
&& !('_group_cursor_params' in p)
|
|
1372
|
-
&& !Boolean(p._pull_gate_locked)) {
|
|
1373
|
-
const explicitCursorParams = this._groupCursorParams(p);
|
|
1374
|
-
if (Object.keys(explicitCursorParams).length > 0) {
|
|
1375
|
-
p._group_cursor_params = explicitCursorParams;
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
// group.* 方法的 group_id 归一化为 canonical 格式(兼容老/污染数据)
|
|
1379
|
-
if (method.startsWith('group.') && p.group_id !== undefined && p.group_id !== null) {
|
|
1380
|
-
const rawGroupId = String(p.group_id);
|
|
1381
|
-
const normalizedGroupId = normalizeGroupId(rawGroupId);
|
|
1382
|
-
if (normalizedGroupId && normalizedGroupId !== rawGroupId) {
|
|
1383
|
-
this._clientLog.debug(`call group_id normalized: ${rawGroupId} -> ${normalizedGroupId} method=${method}`);
|
|
1384
|
-
}
|
|
1385
|
-
p.group_id = normalizedGroupId;
|
|
1386
|
-
}
|
|
1387
|
-
// group.* 方法注入 device_id(服务端用于多设备消息路由)
|
|
1388
|
-
if (method.startsWith('group.') && p.device_id === undefined) {
|
|
1389
|
-
p.device_id = this._deviceId;
|
|
1390
|
-
}
|
|
1391
|
-
if (method.startsWith('group.') && p.slot_id === undefined) {
|
|
1392
|
-
p.slot_id = this._slotId;
|
|
1393
|
-
}
|
|
1013
|
+
const p = this._rpcPipeline.preflight(method, params).params;
|
|
1394
1014
|
// 自动加密:message.send 默认加密(encrypt 默认 true)— V2-only
|
|
1395
1015
|
if (method === 'message.send') {
|
|
1396
1016
|
const encrypt = p.encrypt !== undefined ? p.encrypt : true;
|
|
@@ -1498,101 +1118,13 @@ export class AUNClient {
|
|
|
1498
1118
|
return await this._ackGroupV2(String(p.group_id), Number(p.seq ?? p.msg_seq ?? p.up_to_seq ?? 0) || undefined);
|
|
1499
1119
|
}
|
|
1500
1120
|
// 关键操作自动附加客户端签名
|
|
1501
|
-
|
|
1502
|
-
if (this._shouldSkipClientSignature(method, p)) {
|
|
1503
|
-
delete p.client_signature;
|
|
1504
|
-
}
|
|
1505
|
-
else {
|
|
1506
|
-
await this._signClientOperation(method, p);
|
|
1507
|
-
}
|
|
1508
|
-
}
|
|
1121
|
+
await this._rpcPipeline.applyClientSignature(method, p);
|
|
1509
1122
|
// P1-23: 非幂等方法使用更长超时
|
|
1510
1123
|
const callTimeout = NON_IDEMPOTENT_METHODS.has(method) ? NON_IDEMPOTENT_TIMEOUT : undefined;
|
|
1511
1124
|
let result = callTimeout
|
|
1512
1125
|
? await this._transport.call(method, p, callTimeout)
|
|
1513
1126
|
: await this._transport.call(method, p);
|
|
1514
|
-
|
|
1515
|
-
result = await this._decryptGroupThoughts(result);
|
|
1516
|
-
}
|
|
1517
|
-
if (method === 'message.thought.get' && isJsonObject(result)) {
|
|
1518
|
-
result = await this._decryptMessageThoughts(result);
|
|
1519
|
-
}
|
|
1520
|
-
// V2-only 群状态编排:建群/成员变更后同步 propose+confirm state。
|
|
1521
|
-
const membershipMethods = new Set([
|
|
1522
|
-
'group.create', 'group.add_member', 'group.kick', 'group.remove_member', 'group.leave',
|
|
1523
|
-
'group.review_join_request', 'group.batch_review_join_request',
|
|
1524
|
-
'group.use_invite_code', 'group.request_join',
|
|
1525
|
-
]);
|
|
1526
|
-
if (membershipMethods.has(method) && isJsonObject(result) && !('error' in result) && this._v2Session) {
|
|
1527
|
-
const groupId = this._extractGroupIdFromResult(result) || String(p.group_id ?? '');
|
|
1528
|
-
if (groupId) {
|
|
1529
|
-
try {
|
|
1530
|
-
await this._v2AutoProposeState(groupId);
|
|
1531
|
-
}
|
|
1532
|
-
catch (exc) {
|
|
1533
|
-
this._clientLog.debug(`V2 post-membership propose failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
|
|
1534
|
-
}
|
|
1535
|
-
// group.create / group.use_invite_code 成功后注册 group SPK
|
|
1536
|
-
if (method === 'group.create' || method === 'group.use_invite_code') {
|
|
1537
|
-
const callFn = async (m, ps) => this.call(m, ps);
|
|
1538
|
-
this._v2Session.ensureGroupRegistered?.(groupId, callFn)?.catch(exc => {
|
|
1539
|
-
this._clientLog.debug(`group SPK registration after ${method} failed (non-fatal): group=${groupId} err=${exc}`);
|
|
1540
|
-
});
|
|
1541
|
-
}
|
|
1542
|
-
}
|
|
1543
|
-
}
|
|
1544
|
-
// message.pull 返回后的 seq 跟踪(V2 路径已在 call() 开头路由,此处为 plaintext pull 的 seq 跟踪)
|
|
1545
|
-
if (method === 'message.pull' && isJsonObject(result)) {
|
|
1546
|
-
const r = result;
|
|
1547
|
-
const messages = r.messages;
|
|
1548
|
-
const rawMessages = (Array.isArray(messages) ? messages : []).filter(isJsonObject);
|
|
1549
|
-
if (this._aid) {
|
|
1550
|
-
const ns = `p2p:${this._aid}`;
|
|
1551
|
-
if (rawMessages.length) {
|
|
1552
|
-
const pullAfterSeq = Number(p.after_seq ?? 0) || 0;
|
|
1553
|
-
this._seqTracker.onPullResult(ns, rawMessages, pullAfterSeq);
|
|
1554
|
-
}
|
|
1555
|
-
// ⚠️ 逻辑边界 L1/L3:P2P retention floor 通道 = server_ack_seq
|
|
1556
|
-
const serverAck = Number(r.server_ack_seq ?? 0);
|
|
1557
|
-
if (serverAck > 0) {
|
|
1558
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1559
|
-
if (contig < serverAck) {
|
|
1560
|
-
this._clientLog.info('message.pull retention-floor advance: ns=' + ns + ' contiguous=' + contig + ' -> server_ack_seq=' + serverAck);
|
|
1561
|
-
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
1562
|
-
}
|
|
1563
|
-
}
|
|
1564
|
-
this._saveSeqTrackerState();
|
|
1565
|
-
// auto-ack 延迟到 publish 完成后(由 _fillP2pGap 负责)
|
|
1566
|
-
}
|
|
1567
|
-
}
|
|
1568
|
-
// group.pull 返回后的 seq 跟踪(V2 路径已在 call() 开头路由,此处为 plaintext pull 的 seq 跟踪)
|
|
1569
|
-
if (method === 'group.pull' && isJsonObject(result)) {
|
|
1570
|
-
const r = result;
|
|
1571
|
-
const messages = r.messages;
|
|
1572
|
-
const rawMessages = (Array.isArray(messages) ? messages : []).filter(isJsonObject);
|
|
1573
|
-
const gid = (p.group_id ?? '');
|
|
1574
|
-
if (gid) {
|
|
1575
|
-
const ns = `group:${gid}`;
|
|
1576
|
-
if (rawMessages.length) {
|
|
1577
|
-
const pullAfterSeq = Number(p.after_message_seq ?? p.after_seq ?? 0) || 0;
|
|
1578
|
-
this._seqTracker.onPullResult(ns, rawMessages, pullAfterSeq);
|
|
1579
|
-
}
|
|
1580
|
-
// ⚠️ 逻辑边界 L4:group retention floor 通道 = cursor.current_seq
|
|
1581
|
-
const cursor = isJsonObject(r.cursor) ? r.cursor : null;
|
|
1582
|
-
if (cursor) {
|
|
1583
|
-
const serverAck = Number(cursor.current_seq ?? 0);
|
|
1584
|
-
if (serverAck > 0) {
|
|
1585
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1586
|
-
if (contig < serverAck) {
|
|
1587
|
-
this._clientLog.info('group.pull retention-floor advance: ns=' + ns + ' contiguous=' + contig + ' -> cursor.current_seq=' + serverAck);
|
|
1588
|
-
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
1589
|
-
}
|
|
1590
|
-
}
|
|
1591
|
-
}
|
|
1592
|
-
this._saveSeqTrackerState();
|
|
1593
|
-
// auto-ack 延迟到 publish 完成后(由 _fillGroupGap 负责)
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1127
|
+
result = await this._rpcPipeline.postprocessResult(method, p, result);
|
|
1596
1128
|
// ── Group E2EE 自动编排已移除(V2-only:由 group.v2.bootstrap 驱动)────────
|
|
1597
1129
|
return result;
|
|
1598
1130
|
}
|
|
@@ -1611,15 +1143,7 @@ export class AUNClient {
|
|
|
1611
1143
|
if (method.startsWith('group.') && p.slot_id === undefined) {
|
|
1612
1144
|
p.slot_id = this._slotId;
|
|
1613
1145
|
}
|
|
1614
|
-
|
|
1615
|
-
if (this._shouldSkipClientSignature(method, p)) {
|
|
1616
|
-
delete p.client_signature;
|
|
1617
|
-
}
|
|
1618
|
-
else {
|
|
1619
|
-
await this._signClientOperation(method, p);
|
|
1620
|
-
}
|
|
1621
|
-
}
|
|
1622
|
-
return await this._transport.call(method, p);
|
|
1146
|
+
return await this._rpcPipeline.rawCall(method, p);
|
|
1623
1147
|
}
|
|
1624
1148
|
// ── 事件 ──────────────────────────────────────────
|
|
1625
1149
|
/**
|
|
@@ -1636,141 +1160,27 @@ export class AUNClient {
|
|
|
1636
1160
|
this._dispatcher.unsubscribe(event, handler);
|
|
1637
1161
|
}
|
|
1638
1162
|
// ── 事件管道:消息解密 ────────────────────────────
|
|
1163
|
+
_isEncryptedPushMessage(msg) {
|
|
1164
|
+
return this._v2E2EE.isEncryptedPushMessage(msg);
|
|
1165
|
+
}
|
|
1166
|
+
_attachV2EnvelopeMetadataFromSource(message, source) {
|
|
1167
|
+
attachV2EnvelopeMetadataFromSource(message, source);
|
|
1168
|
+
}
|
|
1639
1169
|
/** 处理 transport 层推送的原始消息:re-publish 给用户(V2 加密消息走 _raw.peer.v2.message_received) */
|
|
1640
1170
|
_onRawMessageReceived(data) {
|
|
1641
|
-
this.
|
|
1642
|
-
this._safeAsync(this._processAndPublishMessage(data));
|
|
1643
|
-
this._clientLog.debug(`_onRawMessageReceived exit: elapsed=0ms (dispatched async)`);
|
|
1171
|
+
this._delivery.onRawMessageReceived(data);
|
|
1644
1172
|
}
|
|
1645
1173
|
/** 实际处理推送消息的异步任务(V2-only:明文消息直接透传,V2 加密消息走 _onV2PushNotification) */
|
|
1646
1174
|
async _processAndPublishMessage(data) {
|
|
1647
|
-
|
|
1648
|
-
if (!isJsonObject(data)) {
|
|
1649
|
-
await this._publishAppEvent('message.received', data);
|
|
1650
|
-
return;
|
|
1651
|
-
}
|
|
1652
|
-
const msg = { ...data };
|
|
1653
|
-
if (!this._messageTargetsCurrentInstance(msg)) {
|
|
1654
|
-
return;
|
|
1655
|
-
}
|
|
1656
|
-
// P2P 空洞检测
|
|
1657
|
-
const seq = msg.seq;
|
|
1658
|
-
const encryptedPush = isEncryptedPushMessage(msg);
|
|
1659
|
-
if (seq !== undefined && seq !== null && this._aid) {
|
|
1660
|
-
const ns = `p2p:${this._aid}`;
|
|
1661
|
-
// Push 修上界:先更新 maxSeenSeq
|
|
1662
|
-
if (seq > 0)
|
|
1663
|
-
this._seqTracker.updateMaxSeen(ns, seq);
|
|
1664
|
-
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1665
|
-
const seqNeedsPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
1666
|
-
const published = encryptedPush
|
|
1667
|
-
? await this._publishEncryptedPushMessage('message.received', 'message.undecryptable', ns, seq, msg, false)
|
|
1668
|
-
: await this._publishOrderedMessage('message.received', ns, seq, msg);
|
|
1669
|
-
const contigAfter = this._seqTracker.getContiguousSeq(ns);
|
|
1670
|
-
const needPull = seqNeedsPull && !published;
|
|
1671
|
-
if (needPull) {
|
|
1672
|
-
this._safeAsync(this._fillP2pGap());
|
|
1673
|
-
}
|
|
1674
|
-
// auto-ack contiguous_seq
|
|
1675
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1676
|
-
if (contig > 0) {
|
|
1677
|
-
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
1678
|
-
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
1679
|
-
this._transport.call('message.ack', {
|
|
1680
|
-
seq: ackSeq,
|
|
1681
|
-
device_id: this._deviceId,
|
|
1682
|
-
slot_id: this._slotId,
|
|
1683
|
-
}).catch((e) => { this._clientLog.warn(`P2P auto-ack failed:${String(e)}`); });
|
|
1684
|
-
}
|
|
1685
|
-
// 即时持久化 cursor,异常断连后不回退
|
|
1686
|
-
if (contigAfter !== contigBefore)
|
|
1687
|
-
this._saveSeqTrackerState();
|
|
1688
|
-
if (encryptedPush)
|
|
1689
|
-
return;
|
|
1690
|
-
}
|
|
1691
|
-
else {
|
|
1692
|
-
if (encryptedPush) {
|
|
1693
|
-
await this._publishEncryptedPushMessage('message.received', 'message.undecryptable', '', seq ?? 0, msg, false);
|
|
1694
|
-
return;
|
|
1695
|
-
}
|
|
1696
|
-
await this._publishAppEvent('message.received', msg);
|
|
1697
|
-
}
|
|
1698
|
-
}
|
|
1699
|
-
catch (exc) {
|
|
1700
|
-
this._clientLog.warn(`P2P push processing failed:${String(exc)}`);
|
|
1701
|
-
if (isJsonObject(data)) {
|
|
1702
|
-
const src = data;
|
|
1703
|
-
const safeEvent = {
|
|
1704
|
-
message_id: (src.message_id ?? null),
|
|
1705
|
-
from: (src.from ?? null),
|
|
1706
|
-
to: (src.to ?? null),
|
|
1707
|
-
seq: (src.seq ?? null),
|
|
1708
|
-
timestamp: (src.timestamp ?? null),
|
|
1709
|
-
_decrypt_error: String(exc),
|
|
1710
|
-
};
|
|
1711
|
-
attachV2EnvelopeMetadataFromSource(safeEvent, data);
|
|
1712
|
-
await this._publishAppEvent('message.undecryptable', safeEvent);
|
|
1713
|
-
}
|
|
1714
|
-
}
|
|
1175
|
+
return this._delivery.processAndPublishMessage(data);
|
|
1715
1176
|
}
|
|
1716
1177
|
/** 处理群组消息推送:re-publish(V2 加密消息走 V2 push 路径) */
|
|
1717
1178
|
_onRawGroupMessageCreated(data) {
|
|
1718
|
-
this.
|
|
1719
|
-
this._safeAsync(this._processAndPublishGroupMessage(data));
|
|
1720
|
-
this._clientLog.debug(`_onRawGroupMessageCreated exit: elapsed=0ms (dispatched async)`);
|
|
1179
|
+
return this._delivery.onRawGroupMessageCreated(data);
|
|
1721
1180
|
}
|
|
1722
1181
|
/** 处理 V2 群消息通知:主动 pull V2 envelope,由 pullGroupV2 解密并发布。 */
|
|
1723
1182
|
async _onRawGroupV2MessageCreated(data) {
|
|
1724
|
-
|
|
1725
|
-
if (!isJsonObject(data)) {
|
|
1726
|
-
this._clientLog.debug(`_onRawGroupV2MessageCreated skipped: non-object type=${typeof data}`);
|
|
1727
|
-
return;
|
|
1728
|
-
}
|
|
1729
|
-
const groupId = String(data.group_id ?? '').trim();
|
|
1730
|
-
const seq = Number(data.seq ?? 0) || 0;
|
|
1731
|
-
const messageId = String(data.message_id ?? '').trim();
|
|
1732
|
-
const senderAid = String(data.sender_aid ?? '').trim();
|
|
1733
|
-
this._clientLog.debug(`_onRawGroupV2MessageCreated group=${groupId} seq=${seq} message_id=${messageId} sender=${senderAid}`);
|
|
1734
|
-
if (!groupId || seq <= 0) {
|
|
1735
|
-
this._clientLog.debug('_onRawGroupV2MessageCreated skipped: missing group_id or seq');
|
|
1736
|
-
return;
|
|
1737
|
-
}
|
|
1738
|
-
if (!this._v2Session) {
|
|
1739
|
-
this._clientLog.debug('_onRawGroupV2MessageCreated skipped: V2 session not initialized');
|
|
1740
|
-
return;
|
|
1741
|
-
}
|
|
1742
|
-
try {
|
|
1743
|
-
const ns = `group:${groupId}`;
|
|
1744
|
-
// Push 修上界:先更新 maxSeenSeq
|
|
1745
|
-
this._seqTracker.updateMaxSeen(ns, seq);
|
|
1746
|
-
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1747
|
-
if (contigBefore === seq) {
|
|
1748
|
-
this._clientLog.debug(`_onRawGroupV2MessageCreated duplicate push already covered: group=${groupId} seq=${seq}`);
|
|
1749
|
-
return;
|
|
1750
|
-
}
|
|
1751
|
-
const afterSeq = this._repairPushContiguousBound(ns, seq, false, '_raw.group.v2.message_created');
|
|
1752
|
-
// per-namespace 去重:同一 group namespace 只允许 1 个 in-flight pull
|
|
1753
|
-
const dedupKey = `group_pull:${ns}`;
|
|
1754
|
-
if (this._gapFillDone.has(dedupKey)) {
|
|
1755
|
-
this._clientLog.debug(`_onRawGroupV2MessageCreated skipped: dedupKey=${dedupKey} in flight`);
|
|
1756
|
-
return;
|
|
1757
|
-
}
|
|
1758
|
-
this._gapFillDone.add(dedupKey);
|
|
1759
|
-
try {
|
|
1760
|
-
this._clientLog.debug(`_onRawGroupV2MessageCreated -> group.v2.pull group=${groupId} after_seq=${afterSeq}`);
|
|
1761
|
-
const messages = await this._pullGroupV2(groupId, afterSeq, 50);
|
|
1762
|
-
this._clientLog.debug(`_onRawGroupV2MessageCreated pulled ${messages.length} msgs for group=${groupId}`);
|
|
1763
|
-
}
|
|
1764
|
-
finally {
|
|
1765
|
-
this._gapFillDone.delete(dedupKey);
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
|
-
catch (exc) {
|
|
1769
|
-
this._clientLog.warn(`_onRawGroupV2MessageCreated pull failed group=${groupId}: ${String(exc)}`);
|
|
1770
|
-
}
|
|
1771
|
-
finally {
|
|
1772
|
-
this._clientLog.debug(`_onRawGroupV2MessageCreated exit: elapsed=${Date.now() - tStart}ms`);
|
|
1773
|
-
}
|
|
1183
|
+
return this._delivery.onRawGroupV2MessageCreated(data);
|
|
1774
1184
|
}
|
|
1775
1185
|
/**
|
|
1776
1186
|
* 处理群组推送消息的异步任务(V2-only:明文消息直接透传)。
|
|
@@ -1779,433 +1189,38 @@ export class AUNClient {
|
|
|
1779
1189
|
* 不带 payload 的事件(通知):自动 pull 最新消息。
|
|
1780
1190
|
*/
|
|
1781
1191
|
async _processAndPublishGroupMessage(data) {
|
|
1782
|
-
|
|
1783
|
-
if (!isJsonObject(data)) {
|
|
1784
|
-
await this._publishAppEvent('group.message_created', data);
|
|
1785
|
-
return;
|
|
1786
|
-
}
|
|
1787
|
-
const msg = { ...data };
|
|
1788
|
-
const groupId = (msg.group_id ?? '');
|
|
1789
|
-
const seq = msg.seq;
|
|
1790
|
-
const payload = msg.payload;
|
|
1791
|
-
// 推送路径收到群消息 → 标记已同步,后续发送无需再 lazySyncGroup
|
|
1792
|
-
if (groupId) {
|
|
1793
|
-
this._groupSynced.add(groupId);
|
|
1794
|
-
}
|
|
1795
|
-
if (payload === undefined || payload === null
|
|
1796
|
-
|| (typeof payload === 'object' && Object.keys(payload).length === 0)) {
|
|
1797
|
-
// 不带 payload 的通知
|
|
1798
|
-
await this._autoPullGroupMessages(msg);
|
|
1799
|
-
return;
|
|
1800
|
-
}
|
|
1801
|
-
// seq 跟踪 + auto-ack
|
|
1802
|
-
const encryptedPush = isEncryptedPushMessage(msg);
|
|
1803
|
-
if (groupId && seq !== undefined && seq !== null) {
|
|
1804
|
-
const ns = `group:${groupId}`;
|
|
1805
|
-
// Push 修上界:先更新 maxSeenSeq
|
|
1806
|
-
if (seq > 0)
|
|
1807
|
-
this._seqTracker.updateMaxSeen(ns, seq);
|
|
1808
|
-
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1809
|
-
const seqNeedsPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
1810
|
-
const published = encryptedPush
|
|
1811
|
-
? await this._publishEncryptedPushMessage('group.message_created', 'group.message_undecryptable', ns, seq, msg, true)
|
|
1812
|
-
: await this._publishOrderedMessage('group.message_created', ns, seq, msg);
|
|
1813
|
-
const contigAfter = this._seqTracker.getContiguousSeq(ns);
|
|
1814
|
-
const needPull = seqNeedsPull && !published;
|
|
1815
|
-
if (needPull) {
|
|
1816
|
-
this._safeAsync(this._fillGroupGap(groupId));
|
|
1817
|
-
}
|
|
1818
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1819
|
-
if (contig > 0) {
|
|
1820
|
-
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
1821
|
-
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
1822
|
-
this._transport.call('group.ack_messages', {
|
|
1823
|
-
group_id: groupId,
|
|
1824
|
-
msg_seq: ackSeq,
|
|
1825
|
-
device_id: this._deviceId,
|
|
1826
|
-
slot_id: this._slotId,
|
|
1827
|
-
}).catch((e) => { this._clientLog.warn('group message auto-ack failed: group=' + groupId, e); });
|
|
1828
|
-
}
|
|
1829
|
-
if (contigAfter !== contigBefore)
|
|
1830
|
-
this._saveSeqTrackerState();
|
|
1831
|
-
if (encryptedPush)
|
|
1832
|
-
return;
|
|
1833
|
-
}
|
|
1834
|
-
else {
|
|
1835
|
-
if (encryptedPush) {
|
|
1836
|
-
await this._publishEncryptedPushMessage('group.message_created', 'group.message_undecryptable', '', seq ?? 0, msg, true);
|
|
1837
|
-
return;
|
|
1838
|
-
}
|
|
1839
|
-
await this._publishAppEvent('group.message_created', msg);
|
|
1840
|
-
}
|
|
1841
|
-
}
|
|
1842
|
-
catch (exc) {
|
|
1843
|
-
this._clientLog.warn(`group push processing failed:${String(exc)}`);
|
|
1844
|
-
if (isJsonObject(data)) {
|
|
1845
|
-
const src = data;
|
|
1846
|
-
const safeEvent = {
|
|
1847
|
-
message_id: (src.message_id ?? null),
|
|
1848
|
-
group_id: (src.group_id ?? null),
|
|
1849
|
-
from: (src.from ?? null),
|
|
1850
|
-
seq: (src.seq ?? null),
|
|
1851
|
-
timestamp: (src.timestamp ?? null),
|
|
1852
|
-
_decrypt_error: String(exc),
|
|
1853
|
-
};
|
|
1854
|
-
attachV2EnvelopeMetadataFromSource(safeEvent, data);
|
|
1855
|
-
await this._publishAppEvent('group.message_undecryptable', safeEvent);
|
|
1856
|
-
}
|
|
1857
|
-
}
|
|
1858
|
-
}
|
|
1859
|
-
async _decryptEncryptedPushPayload(msg, group) {
|
|
1860
|
-
const envelope = encryptedPushEnvelope(msg);
|
|
1861
|
-
if (!isV2EncryptedEnvelopePayload(envelope))
|
|
1862
|
-
return null;
|
|
1863
|
-
const aad = isJsonObject(envelope.aad) ? envelope.aad : {};
|
|
1864
|
-
const fromAid = String(msg.from_aid ?? msg.from ?? msg.sender_aid ?? aad.from ?? '').trim();
|
|
1865
|
-
const plaintext = await this._decryptV2EnvelopeForThought({ envelope, fromAid });
|
|
1866
|
-
if (!plaintext)
|
|
1867
|
-
return null;
|
|
1868
|
-
const e2eeMeta = v2E2eeMeta(envelope);
|
|
1869
|
-
const result = {
|
|
1870
|
-
message_id: String(msg.message_id ?? ''),
|
|
1871
|
-
from: fromAid,
|
|
1872
|
-
seq: msg.seq ?? null,
|
|
1873
|
-
timestamp: msg.t_server ?? msg.timestamp ?? null,
|
|
1874
|
-
payload: plaintext,
|
|
1875
|
-
encrypted: true,
|
|
1876
|
-
e2ee: e2eeMeta,
|
|
1877
|
-
};
|
|
1878
|
-
result.direction = fromAid && fromAid === this._aid ? 'outbound_sync' : 'inbound';
|
|
1879
|
-
if (msg.t_server !== undefined)
|
|
1880
|
-
result.t_server = msg.t_server;
|
|
1881
|
-
if (msg.device_id !== undefined)
|
|
1882
|
-
result.device_id = msg.device_id;
|
|
1883
|
-
if (msg.slot_id !== undefined)
|
|
1884
|
-
result.slot_id = msg.slot_id;
|
|
1885
|
-
if (group) {
|
|
1886
|
-
result.group_id = msg.group_id ?? aad.group_id ?? envelope.group_id ?? null;
|
|
1887
|
-
}
|
|
1888
|
-
else {
|
|
1889
|
-
result.to = msg.to ?? this._aid ?? '';
|
|
1890
|
-
}
|
|
1891
|
-
attachV2EnvelopeMetadata(result, e2eeMeta);
|
|
1892
|
-
return result;
|
|
1893
|
-
}
|
|
1894
|
-
async _publishEncryptedPushAsUndecryptable(event, ns, seq, msg, group) {
|
|
1895
|
-
const safeEvent = safeUndecryptablePushEvent(msg, group);
|
|
1896
|
-
if (ns) {
|
|
1897
|
-
return this._publishOrderedMessage(event, ns, seq, safeEvent);
|
|
1898
|
-
}
|
|
1899
|
-
await this._publishAppEvent(event, safeEvent);
|
|
1900
|
-
return true;
|
|
1192
|
+
return this._delivery.processAndPublishGroupMessage(data);
|
|
1901
1193
|
}
|
|
1902
1194
|
async _publishEncryptedPushMessage(normalEvent, undecryptableEvent, ns, seq, msg, group) {
|
|
1903
|
-
|
|
1904
|
-
if (decrypted) {
|
|
1905
|
-
if (ns)
|
|
1906
|
-
return this._publishOrderedMessage(normalEvent, ns, seq, decrypted);
|
|
1907
|
-
await this._publishAppEvent(normalEvent, decrypted);
|
|
1908
|
-
return true;
|
|
1909
|
-
}
|
|
1910
|
-
return this._publishEncryptedPushAsUndecryptable(undecryptableEvent, ns, seq, msg, group);
|
|
1195
|
+
return await this._v2E2EE.publishEncryptedPushMessage(normalEvent, undecryptableEvent, ns, seq, msg, group);
|
|
1911
1196
|
}
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
const groupId = (notification.group_id ?? '');
|
|
1915
|
-
if (!groupId) {
|
|
1916
|
-
await this._publishAppEvent('group.message_created', notification);
|
|
1917
|
-
return;
|
|
1918
|
-
}
|
|
1919
|
-
const ns = `group:${groupId}`;
|
|
1920
|
-
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1921
|
-
try {
|
|
1922
|
-
// V2-only 模式:走 group.v2.pull(合并 V1 明文 + V2 密文并自动解密)
|
|
1923
|
-
if (this._v2Session) {
|
|
1924
|
-
await this._pullGroupV2Internal({ group_id: groupId, after_seq: afterSeq, limit: 50 });
|
|
1925
|
-
return;
|
|
1926
|
-
}
|
|
1927
|
-
const result = await this.call('group.pull', {
|
|
1928
|
-
group_id: groupId,
|
|
1929
|
-
after_message_seq: afterSeq,
|
|
1930
|
-
device_id: this._deviceId,
|
|
1931
|
-
limit: 50,
|
|
1932
|
-
});
|
|
1933
|
-
if (isJsonObject(result)) {
|
|
1934
|
-
const messages = result.messages;
|
|
1935
|
-
if (Array.isArray(messages)) {
|
|
1936
|
-
// ⚠️ 不再重复调用 onPullResult:call('group.pull') 拦截器已在内部调用过一次
|
|
1937
|
-
const pushed = this._pushedSeqs.get(ns);
|
|
1938
|
-
for (const msg of messages) {
|
|
1939
|
-
if (isJsonObject(msg)) {
|
|
1940
|
-
const s = msg.seq;
|
|
1941
|
-
if (pushed && s !== undefined && s !== null && pushed.has(s)) {
|
|
1942
|
-
continue; // 已发布到应用层,跳过
|
|
1943
|
-
}
|
|
1944
|
-
if (s !== undefined && s !== null) {
|
|
1945
|
-
await this._publishPulledMessage('group.message_created', ns, s, msg);
|
|
1946
|
-
}
|
|
1947
|
-
else {
|
|
1948
|
-
await this._publishAppEvent('group.message_created', msg);
|
|
1949
|
-
}
|
|
1950
|
-
}
|
|
1951
|
-
}
|
|
1952
|
-
this._prunePushedSeqs(ns);
|
|
1953
|
-
return;
|
|
1954
|
-
}
|
|
1955
|
-
}
|
|
1956
|
-
}
|
|
1957
|
-
catch (exc) {
|
|
1958
|
-
this._clientLog.warn(`auto pull group message failed:${String(exc)}`);
|
|
1959
|
-
}
|
|
1960
|
-
// pull 失败时仍透传原始通知
|
|
1961
|
-
await this._publishAppEvent('group.message_created', notification);
|
|
1197
|
+
async _decryptV2PushMessage(data) {
|
|
1198
|
+
return await this._v2E2EE.decryptV2PushMessage(data);
|
|
1962
1199
|
}
|
|
1963
1200
|
/** 后台补齐群消息空洞 */
|
|
1964
1201
|
async _fillGroupGap(groupId) {
|
|
1965
|
-
|
|
1966
|
-
if (this._state !== 'connected' || this._closing)
|
|
1967
|
-
return;
|
|
1968
|
-
groupId = normalizeGroupId(groupId) || String(groupId ?? '').trim();
|
|
1969
|
-
if (!groupId)
|
|
1970
|
-
return;
|
|
1971
|
-
const ns = `group:${groupId}`;
|
|
1972
|
-
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1973
|
-
// per-namespace 去重:同一 group namespace 只允许 1 个 in-flight pull
|
|
1974
|
-
const dedupKey = `group_pull:${ns}`;
|
|
1975
|
-
if (this._gapFillDone.has(dedupKey))
|
|
1976
|
-
return;
|
|
1977
|
-
this._gapFillDone.add(dedupKey);
|
|
1978
|
-
this._gapFillActive = true;
|
|
1979
|
-
let filled = 0;
|
|
1980
|
-
try {
|
|
1981
|
-
const messages = await this._pullGroupV2(groupId, afterSeq, 50);
|
|
1982
|
-
filled = messages.length;
|
|
1983
|
-
this._prunePushedSeqs(ns);
|
|
1984
|
-
}
|
|
1985
|
-
catch (exc) {
|
|
1986
|
-
this._clientLog.warn(`group message gap-fill failed:${String(exc)}`);
|
|
1987
|
-
}
|
|
1988
|
-
finally {
|
|
1989
|
-
// S1: 成功 / 失败路径都必须清理飞行标记
|
|
1990
|
-
this._gapFillDone.delete(dedupKey);
|
|
1991
|
-
this._gapFillActive = false;
|
|
1992
|
-
if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
|
|
1993
|
-
this._safeAsync(this._fillGroupGap(groupId));
|
|
1994
|
-
}
|
|
1995
|
-
}
|
|
1202
|
+
return this._delivery.fillGroupGap(groupId);
|
|
1996
1203
|
}
|
|
1997
1204
|
/** 后台补齐群事件空洞 */
|
|
1998
1205
|
async _fillGroupEventGap(groupId) {
|
|
1999
|
-
|
|
2000
|
-
if (this._state !== 'connected' || this._closing)
|
|
2001
|
-
return;
|
|
2002
|
-
const ns = `group_event:${groupId}`;
|
|
2003
|
-
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
2004
|
-
// per-namespace 去重:同一 group_event namespace 只允许 1 个 in-flight pull
|
|
2005
|
-
const dedupKey = `group_event_pull:${ns}`;
|
|
2006
|
-
if (this._gapFillDone.has(dedupKey))
|
|
2007
|
-
return;
|
|
2008
|
-
this._gapFillDone.add(dedupKey);
|
|
2009
|
-
this._gapFillActive = true;
|
|
2010
|
-
try {
|
|
2011
|
-
let nextAfterSeq = afterSeq;
|
|
2012
|
-
const maxPages = 100;
|
|
2013
|
-
let pageCount = 0;
|
|
2014
|
-
while (pageCount < maxPages) {
|
|
2015
|
-
pageCount += 1;
|
|
2016
|
-
const result = await this.call('group.pull_events', {
|
|
2017
|
-
group_id: groupId,
|
|
2018
|
-
after_event_seq: nextAfterSeq,
|
|
2019
|
-
device_id: this._deviceId,
|
|
2020
|
-
limit: 50,
|
|
2021
|
-
});
|
|
2022
|
-
if (!isJsonObject(result))
|
|
2023
|
-
return;
|
|
2024
|
-
const events = result.events;
|
|
2025
|
-
if (!Array.isArray(events))
|
|
2026
|
-
return;
|
|
2027
|
-
const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
2028
|
-
const eventObjects = events.filter(isJsonObject);
|
|
2029
|
-
if (eventObjects.length > 0) {
|
|
2030
|
-
this._seqTracker.onPullResult(ns, eventObjects, nextAfterSeq);
|
|
2031
|
-
}
|
|
2032
|
-
const cursor = isJsonObject(result.cursor) ? result.cursor : null;
|
|
2033
|
-
const serverAck = cursor ? Number(cursor.current_seq ?? 0) : 0;
|
|
2034
|
-
if (serverAck > 0) {
|
|
2035
|
-
const contigBeforeFloor = this._seqTracker.getContiguousSeq(ns);
|
|
2036
|
-
if (contigBeforeFloor < serverAck) {
|
|
2037
|
-
this._clientLog.info('group.pull_events retention-floor advance: ns=' + ns + ' contiguous=' + contigBeforeFloor + ' -> cursor.current_seq=' + serverAck);
|
|
2038
|
-
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
2039
|
-
}
|
|
2040
|
-
}
|
|
2041
|
-
const eventSeqs = [];
|
|
2042
|
-
for (const evt of eventObjects) {
|
|
2043
|
-
const eventSeq = Number(evt.event_seq ?? 0);
|
|
2044
|
-
if (Number.isFinite(eventSeq) && eventSeq > 0)
|
|
2045
|
-
eventSeqs.push(eventSeq);
|
|
2046
|
-
evt._from_gap_fill = true;
|
|
2047
|
-
const et = String(evt.event_type ?? '');
|
|
2048
|
-
// 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
|
|
2049
|
-
if (et === 'group.message_created')
|
|
2050
|
-
continue;
|
|
2051
|
-
// 验签:有 client_signature 就验(与实时事件路径对齐)
|
|
2052
|
-
const cs = evt.client_signature;
|
|
2053
|
-
if (cs && typeof cs === 'object') {
|
|
2054
|
-
if (this._shouldSkipEventSignature(evt)) {
|
|
2055
|
-
delete evt.client_signature;
|
|
2056
|
-
}
|
|
2057
|
-
else {
|
|
2058
|
-
evt._verified = await this._verifyEventSignature(evt, cs);
|
|
2059
|
-
}
|
|
2060
|
-
}
|
|
2061
|
-
// group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
|
|
2062
|
-
await this._dispatcher.publish('group.changed', evt);
|
|
2063
|
-
}
|
|
2064
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2065
|
-
if (contig !== pageContigBefore) {
|
|
2066
|
-
this._saveSeqTrackerState();
|
|
2067
|
-
}
|
|
2068
|
-
if (eventObjects.length > 0 && contig > 0 && contig !== pageContigBefore) {
|
|
2069
|
-
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
2070
|
-
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
2071
|
-
this._transport.call('group.ack_events', {
|
|
2072
|
-
group_id: groupId,
|
|
2073
|
-
event_seq: ackSeq,
|
|
2074
|
-
device_id: this._deviceId,
|
|
2075
|
-
slot_id: this._slotId,
|
|
2076
|
-
}).catch((e) => { this._clientLog.warn('group event auto-ack failed: group=' + groupId, e); });
|
|
2077
|
-
}
|
|
2078
|
-
const nextAfter = Math.max(eventSeqs.length > 0 ? Math.max(...eventSeqs) : nextAfterSeq, nextAfterSeq);
|
|
2079
|
-
if (eventObjects.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
|
|
2080
|
-
break;
|
|
2081
|
-
nextAfterSeq = nextAfter;
|
|
2082
|
-
}
|
|
2083
|
-
if (pageCount >= maxPages) {
|
|
2084
|
-
this._clientLog.warn(`group event gap fill reached max_pages=${maxPages} group=${groupId} after_seq=${nextAfterSeq}`);
|
|
2085
|
-
}
|
|
2086
|
-
}
|
|
2087
|
-
catch (exc) {
|
|
2088
|
-
this._clientLog.warn(`group event gap-fill failed:${String(exc)}`);
|
|
2089
|
-
}
|
|
2090
|
-
finally {
|
|
2091
|
-
// S1: 成功 / 失败路径都必须清理飞行标记
|
|
2092
|
-
this._gapFillDone.delete(dedupKey);
|
|
2093
|
-
this._gapFillActive = false;
|
|
2094
|
-
}
|
|
1206
|
+
return this._delivery.fillGroupEventGap(groupId);
|
|
2095
1207
|
}
|
|
2096
1208
|
/** 后台补齐 P2P 消息空洞 */
|
|
2097
1209
|
async _fillP2pGap() {
|
|
2098
|
-
|
|
2099
|
-
if (this._state !== 'connected' || this._closing)
|
|
2100
|
-
return;
|
|
2101
|
-
if (!this._aid)
|
|
2102
|
-
return;
|
|
2103
|
-
const ns = `p2p:${this._aid}`;
|
|
2104
|
-
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
2105
|
-
// per-namespace 去重:同一 namespace 只允许 1 个 in-flight pull
|
|
2106
|
-
const dedupKey = `p2p_pull:${ns}`;
|
|
2107
|
-
if (this._gapFillDone.has(dedupKey))
|
|
2108
|
-
return;
|
|
2109
|
-
this._gapFillDone.add(dedupKey);
|
|
2110
|
-
this._gapFillActive = true;
|
|
2111
|
-
let filled = 0;
|
|
2112
|
-
try {
|
|
2113
|
-
const messages = await this._pullV2(afterSeq, 50);
|
|
2114
|
-
filled = messages.length;
|
|
2115
|
-
this._prunePushedSeqs(ns);
|
|
2116
|
-
}
|
|
2117
|
-
catch (exc) {
|
|
2118
|
-
this._clientLog.warn(`P2P message gap-fill failed:${String(exc)}`);
|
|
2119
|
-
}
|
|
2120
|
-
finally {
|
|
2121
|
-
// S1: 成功 / 失败路径都必须清理飞行标记
|
|
2122
|
-
this._gapFillDone.delete(dedupKey);
|
|
2123
|
-
this._gapFillActive = false;
|
|
2124
|
-
if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
|
|
2125
|
-
this._safeAsync(this._fillP2pGap());
|
|
2126
|
-
}
|
|
2127
|
-
}
|
|
1210
|
+
return this._delivery.fillP2pGap();
|
|
2128
1211
|
}
|
|
2129
1212
|
/** 只按硬上限裁剪 published guard,不能按 contiguousSeq 清理。 */
|
|
2130
1213
|
_prunePushedSeqs(ns) {
|
|
2131
|
-
|
|
2132
|
-
if (!pushed)
|
|
2133
|
-
return;
|
|
2134
|
-
if (pushed.size > PUSHED_SEQS_LIMIT) {
|
|
2135
|
-
const keep = [...pushed].sort((a, b) => a - b).slice(-PUSHED_SEQS_LIMIT);
|
|
2136
|
-
this._pushedSeqs.set(ns, new Set(keep));
|
|
2137
|
-
}
|
|
1214
|
+
this._delivery.prunePushedSeqs(ns);
|
|
2138
1215
|
}
|
|
2139
1216
|
_markPublishedSeq(ns, seq) {
|
|
2140
|
-
|
|
2141
|
-
if (!pushed) {
|
|
2142
|
-
pushed = new Set();
|
|
2143
|
-
this._pushedSeqs.set(ns, pushed);
|
|
2144
|
-
}
|
|
2145
|
-
pushed.add(seq);
|
|
2146
|
-
if (pushed.size > PUSHED_SEQS_LIMIT) {
|
|
2147
|
-
const keep = [...pushed].sort((a, b) => a - b).slice(-PUSHED_SEQS_LIMIT);
|
|
2148
|
-
this._pushedSeqs.set(ns, new Set(keep));
|
|
2149
|
-
}
|
|
2150
|
-
}
|
|
2151
|
-
_enqueueOrderedMessage(ns, event, seq, payload) {
|
|
2152
|
-
let queue = this._pendingOrderedMsgs.get(ns);
|
|
2153
|
-
if (!queue) {
|
|
2154
|
-
queue = new Map();
|
|
2155
|
-
this._pendingOrderedMsgs.set(ns, queue);
|
|
2156
|
-
}
|
|
2157
|
-
queue.set(seq, { event, payload });
|
|
2158
|
-
if (queue.size > PENDING_ORDERED_LIMIT) {
|
|
2159
|
-
const drop = [...queue.keys()].sort((a, b) => a - b).slice(0, queue.size - PENDING_ORDERED_LIMIT);
|
|
2160
|
-
for (const oldSeq of drop)
|
|
2161
|
-
queue.delete(oldSeq);
|
|
2162
|
-
}
|
|
2163
|
-
}
|
|
2164
|
-
_isInstanceScopedMessageEvent(event) {
|
|
2165
|
-
return event === 'message.received'
|
|
2166
|
-
|| event === 'message.undecryptable'
|
|
2167
|
-
|| event === 'group.message_created'
|
|
2168
|
-
|| event === 'group.message_undecryptable';
|
|
1217
|
+
this._delivery.markPublishedSeq(ns, seq);
|
|
2169
1218
|
}
|
|
2170
1219
|
_attachCurrentInstanceContext(payload) {
|
|
2171
|
-
|
|
2172
|
-
return payload;
|
|
2173
|
-
const result = { ...payload };
|
|
2174
|
-
if (!('device_id' in result)) {
|
|
2175
|
-
result.device_id = this._deviceId;
|
|
2176
|
-
}
|
|
2177
|
-
if (!('slot_id' in result)) {
|
|
2178
|
-
result.slot_id = this._slotId;
|
|
2179
|
-
}
|
|
2180
|
-
return result;
|
|
2181
|
-
}
|
|
2182
|
-
_normalizePublishedMessagePayload(event, payload) {
|
|
2183
|
-
if (!this._isInstanceScopedMessageEvent(event))
|
|
2184
|
-
return payload;
|
|
2185
|
-
return this._attachCurrentInstanceContext(payload);
|
|
1220
|
+
return this._delivery.attachCurrentInstanceContext(payload);
|
|
2186
1221
|
}
|
|
2187
1222
|
async _publishAppEvent(event, payload) {
|
|
2188
|
-
|
|
2189
|
-
this._maybeAppendEchoTraceReceive(payload);
|
|
2190
|
-
}
|
|
2191
|
-
// 注入本地/远端 agent.md etag,让应用层判断版本一致性;失败不影响业务。
|
|
2192
|
-
if (isJsonObject(payload)) {
|
|
2193
|
-
try {
|
|
2194
|
-
const snapshot = this._agentMdManager.eventSnapshot();
|
|
2195
|
-
const localEtag = snapshot?.local_etag || '';
|
|
2196
|
-
const remoteEtag = snapshot?.remote_etag || '';
|
|
2197
|
-
if ((localEtag || remoteEtag) && payload._agent_md === undefined) {
|
|
2198
|
-
payload._agent_md = {
|
|
2199
|
-
local_etag: localEtag,
|
|
2200
|
-
remote_etag: remoteEtag,
|
|
2201
|
-
};
|
|
2202
|
-
}
|
|
2203
|
-
}
|
|
2204
|
-
catch (exc) {
|
|
2205
|
-
this._clientLog.debug(`agent_md etag inject skipped: ${String(exc)}`);
|
|
2206
|
-
}
|
|
2207
|
-
}
|
|
2208
|
-
await this._dispatcher.publish(event, this._normalizePublishedMessagePayload(event, payload));
|
|
1223
|
+
await this._delivery.publishAppEvent(event, payload);
|
|
2209
1224
|
}
|
|
2210
1225
|
_echoTimestamp() {
|
|
2211
1226
|
const now = new Date();
|
|
@@ -2231,13 +1246,6 @@ export class AUNClient {
|
|
|
2231
1246
|
const trace = `${this._echoTimestamp()} [AUN-SDK.send] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
|
|
2232
1247
|
params.payload = { ...payload, text: payload.text + '\n' + trace };
|
|
2233
1248
|
}
|
|
2234
|
-
_shouldSkipClientSignature(method, params) {
|
|
2235
|
-
if (method !== 'message.send' && method !== 'group.send')
|
|
2236
|
-
return false;
|
|
2237
|
-
if (params.encrypted || params.encrypt)
|
|
2238
|
-
return false;
|
|
2239
|
-
return this._isEchoPayload(params.payload);
|
|
2240
|
-
}
|
|
2241
1249
|
_shouldSkipEventSignature(event) {
|
|
2242
1250
|
if (event.encrypted || event.encrypt)
|
|
2243
1251
|
return false;
|
|
@@ -2254,90 +1262,16 @@ export class AUNClient {
|
|
|
2254
1262
|
msg.payload = { ...payload, text: payload.text + '\n' + trace };
|
|
2255
1263
|
}
|
|
2256
1264
|
_messageTargetsCurrentInstance(message) {
|
|
2257
|
-
|
|
2258
|
-
return true;
|
|
2259
|
-
if ('device_id' in message) {
|
|
2260
|
-
const targetDeviceId = String(message.device_id ?? '').trim();
|
|
2261
|
-
if (targetDeviceId !== this._deviceId) {
|
|
2262
|
-
return false;
|
|
2263
|
-
}
|
|
2264
|
-
}
|
|
2265
|
-
if ('slot_id' in message) {
|
|
2266
|
-
const targetSlotId = String(message.slot_id ?? '').trim();
|
|
2267
|
-
if (slotIsolationKey(targetSlotId) !== slotIsolationKey(this._slotId)) {
|
|
2268
|
-
return false;
|
|
2269
|
-
}
|
|
2270
|
-
}
|
|
2271
|
-
return true;
|
|
1265
|
+
return this._delivery.messageTargetsCurrentInstance(message);
|
|
2272
1266
|
}
|
|
2273
1267
|
async _drainOrderedMessages(ns, beforeSeq) {
|
|
2274
|
-
|
|
2275
|
-
if (!queue || queue.size === 0)
|
|
2276
|
-
return;
|
|
2277
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2278
|
-
const ready = [...queue.keys()]
|
|
2279
|
-
.filter((seq) => seq <= contig && (beforeSeq === undefined || seq < beforeSeq))
|
|
2280
|
-
.sort((a, b) => a - b);
|
|
2281
|
-
for (const seq of ready) {
|
|
2282
|
-
const item = queue.get(seq);
|
|
2283
|
-
queue.delete(seq);
|
|
2284
|
-
if (!item || this._pushedSeqs.get(ns)?.has(seq))
|
|
2285
|
-
continue;
|
|
2286
|
-
await this._publishAppEvent(item.event, item.payload);
|
|
2287
|
-
this._markPublishedSeq(ns, seq);
|
|
2288
|
-
}
|
|
2289
|
-
if (queue.size === 0)
|
|
2290
|
-
this._pendingOrderedMsgs.delete(ns);
|
|
1268
|
+
await this._delivery.drainOrderedMessages(ns, beforeSeq);
|
|
2291
1269
|
}
|
|
2292
1270
|
async _publishOrderedMessage(event, ns, seq, payload) {
|
|
2293
|
-
|
|
2294
|
-
if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0) {
|
|
2295
|
-
await this._publishAppEvent(event, payload);
|
|
2296
|
-
return true;
|
|
2297
|
-
}
|
|
2298
|
-
if (this._pushedSeqs.get(ns)?.has(seqNum)) {
|
|
2299
|
-
const queue = this._pendingOrderedMsgs.get(ns);
|
|
2300
|
-
queue?.delete(seqNum);
|
|
2301
|
-
if (queue && queue.size === 0)
|
|
2302
|
-
this._pendingOrderedMsgs.delete(ns);
|
|
2303
|
-
return false;
|
|
2304
|
-
}
|
|
2305
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2306
|
-
if (seqNum > contig) {
|
|
2307
|
-
this._enqueueOrderedMessage(ns, event, seqNum, payload);
|
|
2308
|
-
return false;
|
|
2309
|
-
}
|
|
2310
|
-
await this._drainOrderedMessages(ns, seqNum);
|
|
2311
|
-
if (this._pushedSeqs.get(ns)?.has(seqNum))
|
|
2312
|
-
return false;
|
|
2313
|
-
const queue = this._pendingOrderedMsgs.get(ns);
|
|
2314
|
-
queue?.delete(seqNum);
|
|
2315
|
-
if (queue && queue.size === 0)
|
|
2316
|
-
this._pendingOrderedMsgs.delete(ns);
|
|
2317
|
-
await this._publishAppEvent(event, payload);
|
|
2318
|
-
this._markPublishedSeq(ns, seqNum);
|
|
2319
|
-
await this._drainOrderedMessages(ns);
|
|
2320
|
-
return true;
|
|
1271
|
+
return this._delivery.publishOrderedMessage(event, ns, seq, payload);
|
|
2321
1272
|
}
|
|
2322
1273
|
async _publishPulledMessage(event, ns, seq, payload) {
|
|
2323
|
-
|
|
2324
|
-
if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0 || !ns) {
|
|
2325
|
-
await this._publishAppEvent(event, payload);
|
|
2326
|
-
return true;
|
|
2327
|
-
}
|
|
2328
|
-
const queue = this._pendingOrderedMsgs.get(ns);
|
|
2329
|
-
if (this._pushedSeqs.get(ns)?.has(seqNum)) {
|
|
2330
|
-
queue?.delete(seqNum);
|
|
2331
|
-
if (queue && queue.size === 0)
|
|
2332
|
-
this._pendingOrderedMsgs.delete(ns);
|
|
2333
|
-
return false;
|
|
2334
|
-
}
|
|
2335
|
-
queue?.delete(seqNum);
|
|
2336
|
-
if (queue && queue.size === 0)
|
|
2337
|
-
this._pendingOrderedMsgs.delete(ns);
|
|
2338
|
-
await this._publishAppEvent(event, payload);
|
|
2339
|
-
this._markPublishedSeq(ns, seqNum);
|
|
2340
|
-
return true;
|
|
1274
|
+
return this._delivery.publishPulledMessage(event, ns, seq, payload);
|
|
2341
1275
|
}
|
|
2342
1276
|
_extractGroupIdFromResult(result) {
|
|
2343
1277
|
const group = isJsonObject(result.group) ? result.group : null;
|
|
@@ -2370,52 +1304,8 @@ export class AUNClient {
|
|
|
2370
1304
|
}
|
|
2371
1305
|
await this._dispatcher.publish('group.changed', d);
|
|
2372
1306
|
const groupId = (d.group_id ?? '');
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
this._v2BootstrapCache.delete(`group:${groupId}`);
|
|
2376
|
-
}
|
|
2377
|
-
// Group SPK 编排:成员变更触发注册/轮换
|
|
2378
|
-
const membershipActions = new Set([
|
|
2379
|
-
'member_added', 'member_left', 'member_removed', 'role_changed',
|
|
2380
|
-
'owner_transferred', 'joined', 'join_approved', 'invite_code_used',
|
|
2381
|
-
]);
|
|
2382
|
-
if (this._v2Session && groupId) {
|
|
2383
|
-
if (membershipActions.has(action)) {
|
|
2384
|
-
const callFn = async (method, params) => this.call(method, params);
|
|
2385
|
-
const joinedAid = String(d.joined_aid ?? d.member_aid ?? d.aid ?? '').trim();
|
|
2386
|
-
const actorAid = String(d.actor_aid ?? '').trim();
|
|
2387
|
-
const selfAid = String(this._aid ?? '').trim();
|
|
2388
|
-
const joinActions = new Set(['member_added', 'joined', 'join_approved', 'invite_code_used']);
|
|
2389
|
-
const isSelfJoin = joinActions.has(action) && !!selfAid && (joinedAid === selfAid ||
|
|
2390
|
-
(!joinedAid && (action === 'joined' || action === 'invite_code_used') && actorAid === selfAid));
|
|
2391
|
-
if (isSelfJoin) {
|
|
2392
|
-
this._v2Session.ensureGroupRegistered?.(groupId, callFn)?.catch(exc => {
|
|
2393
|
-
this._clientLog.debug(`group SPK registration failed (non-fatal): group=${groupId} action=${action} err=${exc}`);
|
|
2394
|
-
});
|
|
2395
|
-
}
|
|
2396
|
-
else {
|
|
2397
|
-
this._v2Session.rotateGroupSPK?.(groupId, callFn)?.catch(exc => {
|
|
2398
|
-
this._clientLog.debug(`group SPK rotation failed (non-fatal): group=${groupId} action=${action} err=${exc}`);
|
|
2399
|
-
});
|
|
2400
|
-
}
|
|
2401
|
-
}
|
|
2402
|
-
}
|
|
2403
|
-
if (groupId && this._v2Session && (action === 'upsert' || membershipActions.has(action))) {
|
|
2404
|
-
this._safeAsync(this._v2AutoProposeState(groupId, { leaderDelay: true }));
|
|
2405
|
-
}
|
|
2406
|
-
// event_seq 空洞检测:持久化后的 group.changed 会携带 event_seq。
|
|
2407
|
-
let needPull = false;
|
|
2408
|
-
const rawEventSeq = d.event_seq;
|
|
2409
|
-
if (rawEventSeq != null && groupId) {
|
|
2410
|
-
const es = Number(rawEventSeq);
|
|
2411
|
-
if (Number.isFinite(es) && es > 0) {
|
|
2412
|
-
needPull = this._seqTracker.onMessageSeq(`group_event:${groupId}`, es);
|
|
2413
|
-
}
|
|
2414
|
-
}
|
|
2415
|
-
// 仅在真实 event gap 时才触发补拉(补洞回来的事件不再触发新补洞)
|
|
2416
|
-
if (needPull && groupId && !d._from_gap_fill) {
|
|
2417
|
-
this._safeAsync(this._fillGroupEventGap(groupId));
|
|
2418
|
-
}
|
|
1307
|
+
this._groupState.handleGroupChangedV2Membership(d);
|
|
1308
|
+
this._delivery.handleGroupChangedEventSeq(d, groupId);
|
|
2419
1309
|
// 群组解散 → 清理本地 seq_tracker、补洞去重缓存
|
|
2420
1310
|
if (d.action === 'dissolved') {
|
|
2421
1311
|
if (groupId) {
|
|
@@ -2439,123 +1329,7 @@ export class AUNClient {
|
|
|
2439
1329
|
* 当 prev_state_hash 与本地不连续时回源 group.get_state,并对回源数据做 hash 验证。
|
|
2440
1330
|
*/
|
|
2441
1331
|
async _onGroupStateCommitted(data) {
|
|
2442
|
-
|
|
2443
|
-
const groupIdInit = String(data?.group_id ?? '');
|
|
2444
|
-
this._clientLog.debug(`_onGroupStateCommitted enter: group_id=${groupIdInit} state_version=${String(data?.state_version ?? '-')}`);
|
|
2445
|
-
try {
|
|
2446
|
-
if (!isJsonObject(data)) {
|
|
2447
|
-
this._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms reason=non_object`);
|
|
2448
|
-
return;
|
|
2449
|
-
}
|
|
2450
|
-
const d = data;
|
|
2451
|
-
const groupId = String(d.group_id ?? '').trim();
|
|
2452
|
-
if (!groupId) {
|
|
2453
|
-
this._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms reason=no_group_id`);
|
|
2454
|
-
return;
|
|
2455
|
-
}
|
|
2456
|
-
await this._onGroupStateCommittedImpl(d, groupId);
|
|
2457
|
-
this._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms group_id=${groupId}`);
|
|
2458
|
-
}
|
|
2459
|
-
catch (err) {
|
|
2460
|
-
this._clientLog.debug(`_onGroupStateCommitted exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
2461
|
-
throw err;
|
|
2462
|
-
}
|
|
2463
|
-
}
|
|
2464
|
-
async _onGroupStateCommittedImpl(d, groupId) {
|
|
2465
|
-
// 提交者签名验证
|
|
2466
|
-
const cs = d.client_signature;
|
|
2467
|
-
if (cs && isJsonObject(cs)) {
|
|
2468
|
-
if (this._shouldSkipEventSignature(d)) {
|
|
2469
|
-
delete d.client_signature;
|
|
2470
|
-
}
|
|
2471
|
-
else {
|
|
2472
|
-
const verified = await this._verifyEventSignature(d, cs);
|
|
2473
|
-
if (verified === false) {
|
|
2474
|
-
this._clientLog.warn(`state_committed committer signature verify failed group=%s${String(groupId)}`);
|
|
2475
|
-
return;
|
|
2476
|
-
}
|
|
2477
|
-
d._verified = verified;
|
|
2478
|
-
}
|
|
2479
|
-
}
|
|
2480
|
-
const stateVersion = Number(d.state_version ?? 0);
|
|
2481
|
-
const stateHash = String(d.state_hash ?? '').trim();
|
|
2482
|
-
const prevStateHash = String(d.prev_state_hash ?? '').trim();
|
|
2483
|
-
const keyEpoch = Number(d.key_epoch ?? 0);
|
|
2484
|
-
const membershipSnapshot = String(d.membership_snapshot ?? '').trim();
|
|
2485
|
-
const policySnapshot = String(d.policy_snapshot ?? '').trim();
|
|
2486
|
-
// 1. 验证 prev_state_hash 连续性
|
|
2487
|
-
const loadFn = this._tokenStore.loadGroupState;
|
|
2488
|
-
const localState = loadFn
|
|
2489
|
-
? await loadFn.call(this._tokenStore, groupId)
|
|
2490
|
-
: null;
|
|
2491
|
-
if (localState && localState.state_hash && localState.state_hash !== prevStateHash) {
|
|
2492
|
-
this._clientLog.warn('[aun_core] state_hash 链不连续 group=%s local_sv=%d event_sv=%d', groupId, localState.state_version, stateVersion);
|
|
2493
|
-
// 回源同步
|
|
2494
|
-
try {
|
|
2495
|
-
const serverState = await this._transport.call('group.get_state', { group_id: groupId });
|
|
2496
|
-
if (serverState && typeof serverState.state_version !== 'undefined') {
|
|
2497
|
-
const sv = Number(serverState.state_version);
|
|
2498
|
-
const sHash = String(serverState.state_hash ?? '');
|
|
2499
|
-
const sEpoch = Number(serverState.key_epoch ?? 0);
|
|
2500
|
-
const sMembersJson = String(serverState.membership_snapshot ?? '');
|
|
2501
|
-
const sPolicyJson = String(serverState.policy_snapshot ?? '');
|
|
2502
|
-
const sPrev = String(serverState.prev_state_hash ?? '');
|
|
2503
|
-
// 回源也做 hash 验证
|
|
2504
|
-
if (sMembersJson && sHash) {
|
|
2505
|
-
const sMembers = sMembersJson ? JSON.parse(sMembersJson) : [];
|
|
2506
|
-
const sPolicy = sPolicyJson ? JSON.parse(sPolicyJson) : {};
|
|
2507
|
-
const computed = await computeStateHash({
|
|
2508
|
-
groupId, stateVersion: sv, keyEpoch: sEpoch,
|
|
2509
|
-
members: sMembers, policy: sPolicy, prevStateHash: sPrev,
|
|
2510
|
-
});
|
|
2511
|
-
if (computed !== sHash) {
|
|
2512
|
-
this._clientLog.warn('[aun_core] 回源 state_hash 验证失败 group=%s sv=%d expected=%s got=%s', groupId, sv, sHash, computed);
|
|
2513
|
-
return;
|
|
2514
|
-
}
|
|
2515
|
-
}
|
|
2516
|
-
const saveFn = this._tokenStore.saveGroupState;
|
|
2517
|
-
if (saveFn) {
|
|
2518
|
-
await saveFn.call(this._tokenStore, groupId, {
|
|
2519
|
-
group_id: groupId,
|
|
2520
|
-
state_version: sv,
|
|
2521
|
-
state_hash: sHash,
|
|
2522
|
-
key_epoch: sEpoch,
|
|
2523
|
-
membership_json: sMembersJson || membershipSnapshot,
|
|
2524
|
-
policy_json: sPolicyJson || policySnapshot,
|
|
2525
|
-
updated_at: Date.now(),
|
|
2526
|
-
});
|
|
2527
|
-
}
|
|
2528
|
-
}
|
|
2529
|
-
}
|
|
2530
|
-
catch (exc) {
|
|
2531
|
-
this._clientLog.warn(`state pull-back failed group=%s:${groupId} ${exc}`);
|
|
2532
|
-
}
|
|
2533
|
-
return;
|
|
2534
|
-
}
|
|
2535
|
-
// 2. 本地重算验证
|
|
2536
|
-
const members = membershipSnapshot ? JSON.parse(membershipSnapshot) : [];
|
|
2537
|
-
const policy = policySnapshot ? JSON.parse(policySnapshot) : {};
|
|
2538
|
-
const computed = await computeStateHash({
|
|
2539
|
-
groupId, stateVersion, keyEpoch,
|
|
2540
|
-
members, policy, prevStateHash,
|
|
2541
|
-
});
|
|
2542
|
-
if (computed !== stateHash) {
|
|
2543
|
-
this._clientLog.warn('[aun_core] state_hash 重算不匹配 group=%s sv=%d expected=%s got=%s', groupId, stateVersion, stateHash, computed);
|
|
2544
|
-
return;
|
|
2545
|
-
}
|
|
2546
|
-
// 3. 更新本地存储
|
|
2547
|
-
const saveFn = this._tokenStore.saveGroupState;
|
|
2548
|
-
if (saveFn) {
|
|
2549
|
-
await saveFn.call(this._tokenStore, groupId, {
|
|
2550
|
-
group_id: groupId,
|
|
2551
|
-
state_version: stateVersion,
|
|
2552
|
-
state_hash: stateHash,
|
|
2553
|
-
key_epoch: keyEpoch,
|
|
2554
|
-
membership_json: membershipSnapshot,
|
|
2555
|
-
policy_json: policySnapshot,
|
|
2556
|
-
updated_at: Date.now(),
|
|
2557
|
-
});
|
|
2558
|
-
}
|
|
1332
|
+
return this._groupState.onGroupStateCommitted(data);
|
|
2559
1333
|
}
|
|
2560
1334
|
/**
|
|
2561
1335
|
* 群组解散后清理本地状态:
|
|
@@ -2566,7 +1340,7 @@ export class AUNClient {
|
|
|
2566
1340
|
*/
|
|
2567
1341
|
_cleanupDissolvedGroup(groupId) {
|
|
2568
1342
|
// 1. 清理 seq_tracker 中的群消息和群事件命名空间
|
|
2569
|
-
this.
|
|
1343
|
+
this._v2E2EE.deleteBootstrapCacheEntry(`group:${groupId}`);
|
|
2570
1344
|
this._v2GroupSecurityLevels.delete(groupId);
|
|
2571
1345
|
this._v2StateChains.delete(groupId);
|
|
2572
1346
|
this._seqTracker.removeNamespace(`group:${groupId}`);
|
|
@@ -2597,8 +1371,7 @@ export class AUNClient {
|
|
|
2597
1371
|
}
|
|
2598
1372
|
try {
|
|
2599
1373
|
if (expectedFP) {
|
|
2600
|
-
|
|
2601
|
-
if (actualFP !== expectedFP) {
|
|
1374
|
+
if (!(await certMatchesFingerprint(cached.certPem, expectedFP))) {
|
|
2602
1375
|
this._clientLog.warn(`group event sig verify failed: cert fingerprint mismatch aid=%s${String(sigAid)}`);
|
|
2603
1376
|
return false;
|
|
2604
1377
|
}
|
|
@@ -2724,176 +1497,10 @@ export class AUNClient {
|
|
|
2724
1497
|
}
|
|
2725
1498
|
}
|
|
2726
1499
|
async _decryptGroupThoughts(result) {
|
|
2727
|
-
|
|
2728
|
-
return { ...result, thoughts: [] };
|
|
2729
|
-
}
|
|
2730
|
-
const items = (Array.isArray(result.thoughts) ? result.thoughts : []).filter(isJsonObject);
|
|
2731
|
-
if (!items.length) {
|
|
2732
|
-
return { ...result, thoughts: [] };
|
|
2733
|
-
}
|
|
2734
|
-
const groupId = String(result.group_id ?? '');
|
|
2735
|
-
const senderAid = String(result.sender_aid ?? '');
|
|
2736
|
-
const thoughts = [];
|
|
2737
|
-
for (const item of items) {
|
|
2738
|
-
const payload = isJsonObject(item.payload) ? item.payload : null;
|
|
2739
|
-
const thoughtId = String(item.thought_id ?? item.message_id ?? '');
|
|
2740
|
-
// V2 群 thought envelope:per-device wrap
|
|
2741
|
-
const isV2Envelope = payload !== null
|
|
2742
|
-
&& payload.type === 'e2ee.group_encrypted'
|
|
2743
|
-
&& (payload.version === 'v2' || Array.isArray(payload.recipients));
|
|
2744
|
-
let decryptedPayload = payload;
|
|
2745
|
-
let e2eeMeta = null;
|
|
2746
|
-
let decryptFailed = false;
|
|
2747
|
-
if (isV2Envelope) {
|
|
2748
|
-
e2eeMeta = v2E2eeMeta(payload);
|
|
2749
|
-
const plaintext = await this._decryptV2EnvelopeForThought({
|
|
2750
|
-
envelope: payload,
|
|
2751
|
-
fromAid: senderAid,
|
|
2752
|
-
});
|
|
2753
|
-
if (plaintext === null) {
|
|
2754
|
-
this._clientLog.warn(`group.thought.decrypt v2 failed thought_id=${thoughtId}`);
|
|
2755
|
-
decryptFailed = true;
|
|
2756
|
-
decryptedPayload = payload;
|
|
2757
|
-
}
|
|
2758
|
-
else {
|
|
2759
|
-
decryptedPayload = plaintext;
|
|
2760
|
-
const e2eeObj = e2eeMeta;
|
|
2761
|
-
// 暴露 protected_headers(去 _auth)
|
|
2762
|
-
const ph = payload.protected_headers;
|
|
2763
|
-
if (isJsonObject(ph)) {
|
|
2764
|
-
const phBody = {};
|
|
2765
|
-
for (const [k, v] of Object.entries(ph)) {
|
|
2766
|
-
if (k !== '_auth')
|
|
2767
|
-
phBody[k] = v;
|
|
2768
|
-
}
|
|
2769
|
-
if (Object.keys(phBody).length > 0)
|
|
2770
|
-
e2eeObj.protected_headers = phBody;
|
|
2771
|
-
}
|
|
2772
|
-
// 暴露 context(去 _auth)
|
|
2773
|
-
const ctx = payload.context;
|
|
2774
|
-
if (isJsonObject(ctx)) {
|
|
2775
|
-
const ctxBody = {};
|
|
2776
|
-
for (const [k, v] of Object.entries(ctx)) {
|
|
2777
|
-
if (k !== '_auth')
|
|
2778
|
-
ctxBody[k] = v;
|
|
2779
|
-
}
|
|
2780
|
-
if (Object.keys(ctxBody).length > 0)
|
|
2781
|
-
e2eeObj.context = ctxBody;
|
|
2782
|
-
}
|
|
2783
|
-
e2eeMeta = e2eeObj;
|
|
2784
|
-
}
|
|
2785
|
-
}
|
|
2786
|
-
else if (payload?.type === 'e2ee.group_encrypted') {
|
|
2787
|
-
decryptFailed = true;
|
|
2788
|
-
decryptedPayload = payload;
|
|
2789
|
-
}
|
|
2790
|
-
const thought = {
|
|
2791
|
-
thought_id: thoughtId,
|
|
2792
|
-
message_id: thoughtId,
|
|
2793
|
-
payload: decryptedPayload,
|
|
2794
|
-
created_at: item.created_at,
|
|
2795
|
-
e2ee: e2eeMeta,
|
|
2796
|
-
};
|
|
2797
|
-
if (isJsonObject(e2eeMeta))
|
|
2798
|
-
attachV2EnvelopeMetadata(thought, e2eeMeta);
|
|
2799
|
-
if (decryptFailed)
|
|
2800
|
-
thought.decrypt_failed = true;
|
|
2801
|
-
if ('context' in item)
|
|
2802
|
-
thought.context = item.context;
|
|
2803
|
-
thoughts.push(thought);
|
|
2804
|
-
}
|
|
2805
|
-
return { ...result, thoughts };
|
|
1500
|
+
return await this._v2E2EE.decryptGroupThoughts(result);
|
|
2806
1501
|
}
|
|
2807
1502
|
async _decryptMessageThoughts(result) {
|
|
2808
|
-
|
|
2809
|
-
return { ...result, thoughts: [] };
|
|
2810
|
-
}
|
|
2811
|
-
const items = (Array.isArray(result.thoughts) ? result.thoughts : []).filter(isJsonObject);
|
|
2812
|
-
if (!items.length) {
|
|
2813
|
-
return { ...result, thoughts: [] };
|
|
2814
|
-
}
|
|
2815
|
-
const senderAid = String(result.sender_aid ?? '');
|
|
2816
|
-
const peerAid = String(result.peer_aid ?? '');
|
|
2817
|
-
const thoughts = [];
|
|
2818
|
-
for (const item of items) {
|
|
2819
|
-
const payload = isJsonObject(item.payload) ? item.payload : null;
|
|
2820
|
-
const thoughtId = String(item.thought_id ?? item.message_id ?? '');
|
|
2821
|
-
const fromAid = String(item.from ?? senderAid);
|
|
2822
|
-
const toAid = String(item.to ?? peerAid);
|
|
2823
|
-
const message = {
|
|
2824
|
-
from: fromAid,
|
|
2825
|
-
to: toAid,
|
|
2826
|
-
message_id: thoughtId,
|
|
2827
|
-
payload: payload ?? {},
|
|
2828
|
-
encrypted: item.encrypted !== false,
|
|
2829
|
-
timestamp: Number(item.created_at ?? 0),
|
|
2830
|
-
};
|
|
2831
|
-
if (isJsonObject(item.context))
|
|
2832
|
-
message.context = item.context;
|
|
2833
|
-
let decrypted = message;
|
|
2834
|
-
let decryptFailed = false;
|
|
2835
|
-
// V2 P2P thought envelope:per-device wrap,本设备解密自己的 row
|
|
2836
|
-
if (payload?.type === 'e2ee.p2p_encrypted') {
|
|
2837
|
-
const e2eeObj = v2E2eeMeta(payload);
|
|
2838
|
-
message.e2ee = e2eeObj;
|
|
2839
|
-
const plaintext = await this._decryptV2EnvelopeForThought({
|
|
2840
|
-
envelope: payload,
|
|
2841
|
-
fromAid,
|
|
2842
|
-
});
|
|
2843
|
-
if (plaintext === null) {
|
|
2844
|
-
this._clientLog.warn(`p2p.thought.decrypt v2 failed thought_id=${thoughtId}`);
|
|
2845
|
-
decryptFailed = true;
|
|
2846
|
-
}
|
|
2847
|
-
else {
|
|
2848
|
-
decrypted = { ...message };
|
|
2849
|
-
decrypted.payload = plaintext;
|
|
2850
|
-
// 暴露 protected_headers(去 _auth)
|
|
2851
|
-
const ph = payload.protected_headers;
|
|
2852
|
-
if (isJsonObject(ph)) {
|
|
2853
|
-
const phBody = {};
|
|
2854
|
-
for (const [k, v] of Object.entries(ph)) {
|
|
2855
|
-
if (k !== '_auth')
|
|
2856
|
-
phBody[k] = v;
|
|
2857
|
-
}
|
|
2858
|
-
if (Object.keys(phBody).length > 0)
|
|
2859
|
-
e2eeObj.protected_headers = phBody;
|
|
2860
|
-
}
|
|
2861
|
-
// 暴露 context(去 _auth)
|
|
2862
|
-
const ctx = payload.context;
|
|
2863
|
-
if (isJsonObject(ctx)) {
|
|
2864
|
-
const ctxBody = {};
|
|
2865
|
-
for (const [k, v] of Object.entries(ctx)) {
|
|
2866
|
-
if (k !== '_auth')
|
|
2867
|
-
ctxBody[k] = v;
|
|
2868
|
-
}
|
|
2869
|
-
if (Object.keys(ctxBody).length > 0)
|
|
2870
|
-
e2eeObj.context = ctxBody;
|
|
2871
|
-
}
|
|
2872
|
-
decrypted.e2ee = e2eeObj;
|
|
2873
|
-
}
|
|
2874
|
-
}
|
|
2875
|
-
else if (payload?.type === 'e2ee.encrypted') {
|
|
2876
|
-
decryptFailed = true;
|
|
2877
|
-
}
|
|
2878
|
-
const exposedE2ee = (decrypted ?? message).e2ee;
|
|
2879
|
-
const thought = {
|
|
2880
|
-
thought_id: thoughtId,
|
|
2881
|
-
message_id: thoughtId,
|
|
2882
|
-
from: fromAid,
|
|
2883
|
-
to: toAid,
|
|
2884
|
-
payload: (decrypted ?? message).payload,
|
|
2885
|
-
created_at: item.created_at,
|
|
2886
|
-
e2ee: exposedE2ee,
|
|
2887
|
-
};
|
|
2888
|
-
if (isJsonObject(exposedE2ee))
|
|
2889
|
-
attachV2EnvelopeMetadata(thought, exposedE2ee);
|
|
2890
|
-
if (decryptFailed)
|
|
2891
|
-
thought.decrypt_failed = true;
|
|
2892
|
-
if ('context' in item)
|
|
2893
|
-
thought.context = item.context;
|
|
2894
|
-
thoughts.push(thought);
|
|
2895
|
-
}
|
|
2896
|
-
return { ...result, thoughts };
|
|
1503
|
+
return await this._v2E2EE.decryptMessageThoughts(result);
|
|
2897
1504
|
}
|
|
2898
1505
|
// ── E2EE 编排:证书与客户端签名 ──────────────────────
|
|
2899
1506
|
/**
|
|
@@ -2918,51 +1525,27 @@ export class AUNClient {
|
|
|
2918
1525
|
// 跨域时用 peer 所在域的 Gateway URL
|
|
2919
1526
|
const peerGatewayUrl = resolvePeerGatewayUrl(gatewayUrl, aid);
|
|
2920
1527
|
let certPem;
|
|
1528
|
+
const certUrl = buildCertUrl(peerGatewayUrl, aid, certFingerprint);
|
|
1529
|
+
// 兼容旧浏览器,不使用 AbortSignal.timeout(Chrome 103+ 才支持)
|
|
1530
|
+
const controller = new AbortController();
|
|
1531
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
2921
1532
|
try {
|
|
2922
|
-
const
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
1533
|
+
const resp = await fetch(certUrl, { signal: controller.signal });
|
|
1534
|
+
if (!resp.ok)
|
|
1535
|
+
throw new ValidationError(`failed to fetch peer cert for ${aid}: HTTP ${resp.status}`);
|
|
1536
|
+
certPem = await resp.text();
|
|
1537
|
+
}
|
|
1538
|
+
finally {
|
|
1539
|
+
clearTimeout(timeoutId);
|
|
1540
|
+
}
|
|
1541
|
+
// H7: 严格校验指纹(DER SHA-256 或 SPKI SHA-256 任一匹配即可)
|
|
1542
|
+
if (certFingerprint) {
|
|
1543
|
+
const expectedFP = String(certFingerprint).trim().toLowerCase();
|
|
1544
|
+
if (!normalizeFingerprintHex(expectedFP)) {
|
|
1545
|
+
throw new ValidationError(`unsupported cert_fingerprint format for ${aid}: ${expectedFP.slice(0, 24)}`);
|
|
2931
1546
|
}
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
}
|
|
2935
|
-
}
|
|
2936
|
-
catch (exc) {
|
|
2937
|
-
if (!certFingerprint) {
|
|
2938
|
-
throw exc;
|
|
2939
|
-
}
|
|
2940
|
-
// 兼容旧浏览器,不使用 AbortSignal.timeout(Chrome 103+ 才支持)
|
|
2941
|
-
const fallbackController = new AbortController();
|
|
2942
|
-
const fallbackTimeoutId = setTimeout(() => fallbackController.abort(), timeoutMs);
|
|
2943
|
-
try {
|
|
2944
|
-
const fallbackResp = await fetch(buildCertUrl(peerGatewayUrl, aid), { signal: fallbackController.signal });
|
|
2945
|
-
if (!fallbackResp.ok) {
|
|
2946
|
-
throw exc;
|
|
2947
|
-
}
|
|
2948
|
-
certPem = await fallbackResp.text();
|
|
2949
|
-
}
|
|
2950
|
-
finally {
|
|
2951
|
-
clearTimeout(fallbackTimeoutId);
|
|
2952
|
-
}
|
|
2953
|
-
}
|
|
2954
|
-
// H7: 严格校验指纹(DER SHA-256 或 SPKI SHA-256 任一匹配即可)
|
|
2955
|
-
if (certFingerprint) {
|
|
2956
|
-
const expectedFP = String(certFingerprint).trim().toLowerCase();
|
|
2957
|
-
if (!expectedFP.startsWith('sha256:')) {
|
|
2958
|
-
throw new ValidationError(`unsupported cert_fingerprint format for ${aid}: ${expectedFP.slice(0, 24)}`);
|
|
2959
|
-
}
|
|
2960
|
-
const derFP = await this._certFingerprint(certPem);
|
|
2961
|
-
if (derFP !== expectedFP) {
|
|
2962
|
-
const spkiFP = await this._spkiFingerprint(certPem);
|
|
2963
|
-
if (!spkiFP || spkiFP !== expectedFP) {
|
|
2964
|
-
throw new ValidationError(`peer cert fingerprint mismatch for ${aid}: expected=${expectedFP.slice(0, 24)}...`);
|
|
2965
|
-
}
|
|
1547
|
+
if (!(await certMatchesFingerprint(certPem, expectedFP))) {
|
|
1548
|
+
throw new ValidationError(`peer cert fingerprint mismatch for ${aid}: expected=${expectedFP.slice(0, 24)}...`);
|
|
2966
1549
|
}
|
|
2967
1550
|
}
|
|
2968
1551
|
// 完整 PKI 验证:链 + CRL + OCSP + AID 绑定
|
|
@@ -2992,118 +1575,13 @@ export class AUNClient {
|
|
|
2992
1575
|
throw err;
|
|
2993
1576
|
}
|
|
2994
1577
|
}
|
|
2995
|
-
/** 确保发送方证书在本地可用且未过期 */
|
|
2996
|
-
async _ensureSenderCertCached(aid, certFingerprint) {
|
|
2997
|
-
const cacheKey = certCacheKey(aid, certFingerprint);
|
|
2998
|
-
const cached = this._certCache.get(cacheKey);
|
|
2999
|
-
const now = Date.now() / 1000;
|
|
3000
|
-
if (cached && now < cached.refreshAfter)
|
|
3001
|
-
return true;
|
|
3002
|
-
const localCert = await this._tokenStore.loadCert(aid, certFingerprint);
|
|
3003
|
-
if (localCert) {
|
|
3004
|
-
if (certFingerprint) {
|
|
3005
|
-
const actualFingerprint = await this._certFingerprint(localCert);
|
|
3006
|
-
if (actualFingerprint === String(certFingerprint).trim().toLowerCase()) {
|
|
3007
|
-
this._certCache.set(cacheKey, {
|
|
3008
|
-
certPem: localCert,
|
|
3009
|
-
validatedAt: now,
|
|
3010
|
-
refreshAfter: now + PEER_CERT_CACHE_TTL,
|
|
3011
|
-
});
|
|
3012
|
-
return true;
|
|
3013
|
-
}
|
|
3014
|
-
}
|
|
3015
|
-
else {
|
|
3016
|
-
this._certCache.set(cacheKey, {
|
|
3017
|
-
certPem: localCert,
|
|
3018
|
-
validatedAt: now,
|
|
3019
|
-
refreshAfter: now + PEER_CERT_CACHE_TTL,
|
|
3020
|
-
});
|
|
3021
|
-
return true;
|
|
3022
|
-
}
|
|
3023
|
-
}
|
|
3024
|
-
try {
|
|
3025
|
-
const certPem = await this._fetchPeerCert(aid, certFingerprint);
|
|
3026
|
-
// peer 证书只存版本目录,不覆盖 cert.pem
|
|
3027
|
-
await this._tokenStore.saveCert(aid, certPem, certFingerprint, { makeActive: false });
|
|
3028
|
-
return true;
|
|
3029
|
-
}
|
|
3030
|
-
catch (exc) {
|
|
3031
|
-
// 刷新失败时:若缓存有 PKI 验证过的证书(2 倍 TTL 内)则继续用
|
|
3032
|
-
if (cached && now < cached.validatedAt + PEER_CERT_CACHE_TTL * 2) {
|
|
3033
|
-
this._clientLog.warn(`refresh sender ${aid} cert failed, continue using verified memory cache: ${String(exc)}`);
|
|
3034
|
-
return true;
|
|
3035
|
-
}
|
|
3036
|
-
this._clientLog.warn(`fetch sender ${aid} cert failed and no verify cache, reject trust: ${String(exc)}`);
|
|
3037
|
-
return false;
|
|
3038
|
-
}
|
|
3039
|
-
}
|
|
3040
|
-
/**
|
|
3041
|
-
* 获取经过 PKI 验证的 peer 证书(仅信任内存缓存中已验证的证书)。
|
|
3042
|
-
* 零信任要求:不直接信任 keystore 中可能由恶意服务端注入的证书。
|
|
3043
|
-
*/
|
|
3044
|
-
_getVerifiedPeerCert(aid, certFingerprint) {
|
|
3045
|
-
let cached = this._certCache.get(certCacheKey(aid, certFingerprint));
|
|
3046
|
-
// 带 fingerprint 查不到时,降级用 aid 再查一次
|
|
3047
|
-
if (!cached && certFingerprint) {
|
|
3048
|
-
cached = this._certCache.get(certCacheKey(aid, undefined));
|
|
3049
|
-
}
|
|
3050
|
-
const now = Date.now() / 1000;
|
|
3051
|
-
if (cached && now < cached.validatedAt + PEER_CERT_CACHE_TTL * 2) {
|
|
3052
|
-
return cached.certPem;
|
|
3053
|
-
}
|
|
3054
|
-
return null;
|
|
3055
|
-
}
|
|
3056
1578
|
// ── 客户端操作签名 ────────────────────────────────
|
|
3057
1579
|
/**
|
|
3058
1580
|
* 为关键操作附加客户端 ECDSA 签名(_client_signature 字段)。
|
|
3059
1581
|
* 使用 SubtleCrypto 异步签名。
|
|
3060
1582
|
*/
|
|
3061
1583
|
async _signClientOperation(method, params) {
|
|
3062
|
-
|
|
3063
|
-
if (!currentAid?.privateKeyPem)
|
|
3064
|
-
return;
|
|
3065
|
-
try {
|
|
3066
|
-
const aid = currentAid.aid;
|
|
3067
|
-
const ts = String(Math.floor(Date.now() / 1000));
|
|
3068
|
-
// 计算 params hash:覆盖所有非 _ 前缀且非 client_signature 的业务字段
|
|
3069
|
-
const paramsForHash = {};
|
|
3070
|
-
for (const [k, v] of Object.entries(params)) {
|
|
3071
|
-
if (k !== 'client_signature' && !k.startsWith('_')) {
|
|
3072
|
-
paramsForHash[k] = v;
|
|
3073
|
-
}
|
|
3074
|
-
}
|
|
3075
|
-
const paramsJson = stableStringify(paramsForHash);
|
|
3076
|
-
// SHA-256 hash
|
|
3077
|
-
const paramsHashBuf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(paramsJson));
|
|
3078
|
-
const paramsHash = Array.from(new Uint8Array(paramsHashBuf))
|
|
3079
|
-
.map(b => b.toString(16).padStart(2, '0'))
|
|
3080
|
-
.join('');
|
|
3081
|
-
const signData = new TextEncoder().encode(`${method}|${aid}|${ts}|${paramsHash}`);
|
|
3082
|
-
// 导入私钥并签名
|
|
3083
|
-
const pkcs8 = pemToArrayBuffer(currentAid.privateKeyPem);
|
|
3084
|
-
const cryptoKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);
|
|
3085
|
-
const sigP1363 = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, cryptoKey, signData);
|
|
3086
|
-
// P1363 → DER 格式(与 Python 兼容)
|
|
3087
|
-
const sigDer = p1363ToDer(new Uint8Array(sigP1363));
|
|
3088
|
-
// 证书指纹
|
|
3089
|
-
let certFingerprint = '';
|
|
3090
|
-
const certPem = currentAid.certPem;
|
|
3091
|
-
if (certPem) {
|
|
3092
|
-
const certDer = pemToArrayBuffer(certPem);
|
|
3093
|
-
const fpBuf = await crypto.subtle.digest('SHA-256', certDer);
|
|
3094
|
-
certFingerprint = 'sha256:' + Array.from(new Uint8Array(fpBuf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
3095
|
-
}
|
|
3096
|
-
params.client_signature = {
|
|
3097
|
-
aid,
|
|
3098
|
-
cert_fingerprint: certFingerprint,
|
|
3099
|
-
timestamp: ts,
|
|
3100
|
-
params_hash: paramsHash,
|
|
3101
|
-
signature: uint8ToBase64(sigDer),
|
|
3102
|
-
};
|
|
3103
|
-
}
|
|
3104
|
-
catch (exc) {
|
|
3105
|
-
throw new E2EEError(`客户端签名失败: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
3106
|
-
}
|
|
1584
|
+
await this._rpcPipeline.signClientOperation(method, params);
|
|
3107
1585
|
}
|
|
3108
1586
|
// ── 内部:连接 ────────────────────────────────────
|
|
3109
1587
|
async _connectOnce(params, allowReauth) {
|
|
@@ -3165,7 +1643,7 @@ export class AUNClient {
|
|
|
3165
1643
|
}
|
|
3166
1644
|
catch (err) {
|
|
3167
1645
|
// P1-19: 首连失败时重置状态,避免半连接残留
|
|
3168
|
-
this._state = '
|
|
1646
|
+
this._state = 'standby';
|
|
3169
1647
|
try {
|
|
3170
1648
|
await this._transport.close();
|
|
3171
1649
|
}
|
|
@@ -3185,22 +1663,16 @@ export class AUNClient {
|
|
|
3185
1663
|
this._startBackgroundTasks();
|
|
3186
1664
|
const connectionKind = String(params.connection_kind ?? 'long');
|
|
3187
1665
|
const isShortConnection = connectionKind === 'short';
|
|
1666
|
+
const hasExplicitBackgroundSync = Object.prototype.hasOwnProperty.call(params, 'background_sync');
|
|
1667
|
+
const backgroundSyncEnabled = this._sessionOptions?.background_sync !== false
|
|
1668
|
+
&& (!isShortConnection || hasExplicitBackgroundSync);
|
|
3188
1669
|
if (!isShortConnection) {
|
|
3189
|
-
|
|
3190
|
-
try {
|
|
3191
|
-
await this._initV2Session();
|
|
3192
|
-
}
|
|
3193
|
-
catch (exc) {
|
|
3194
|
-
this._clientLog.warn(`V2 session init failed (non-fatal): ${String(exc)}`);
|
|
3195
|
-
}
|
|
1670
|
+
await this._v2E2EE.onConnected({ backgroundSync: backgroundSyncEnabled });
|
|
3196
1671
|
}
|
|
3197
1672
|
else {
|
|
3198
1673
|
this._clientLog.debug('V2 session init deferred for short connection');
|
|
3199
1674
|
}
|
|
3200
1675
|
// connect/reconnect 成功后自动触发一次 P2P message.pull,补齐离线期间积压
|
|
3201
|
-
const hasExplicitBackgroundSync = Object.prototype.hasOwnProperty.call(params, 'background_sync');
|
|
3202
|
-
const backgroundSyncEnabled = this._sessionOptions?.background_sync !== false
|
|
3203
|
-
&& (!isShortConnection || hasExplicitBackgroundSync);
|
|
3204
1676
|
if (backgroundSyncEnabled) {
|
|
3205
1677
|
this._safeAsync(this._fillP2pGap());
|
|
3206
1678
|
}
|
|
@@ -3416,11 +1888,7 @@ export class AUNClient {
|
|
|
3416
1888
|
this._gapFillDone = new Set(arr.slice(arr.length - 5000));
|
|
3417
1889
|
}
|
|
3418
1890
|
const now = Date.now();
|
|
3419
|
-
|
|
3420
|
-
if (now - entry.cachedAt >= AUNClient.V2_BOOTSTRAP_TTL_MS) {
|
|
3421
|
-
this._v2BootstrapCache.delete(key);
|
|
3422
|
-
}
|
|
3423
|
-
}
|
|
1891
|
+
this._v2E2EE.pruneExpiredBootstrapCache(AUNClient.V2_BOOTSTRAP_TTL_MS, now);
|
|
3424
1892
|
for (const [key, exp] of this._v2SigCache) {
|
|
3425
1893
|
if (exp <= now)
|
|
3426
1894
|
this._v2SigCache.delete(key);
|
|
@@ -3575,78 +2043,14 @@ export class AUNClient {
|
|
|
3575
2043
|
};
|
|
3576
2044
|
scheduleRefresh(0);
|
|
3577
2045
|
}
|
|
3578
|
-
_normalizeOutboundMessagePayload(params, method = '') {
|
|
3579
|
-
if (!Object.prototype.hasOwnProperty.call(params, 'payload') && Object.prototype.hasOwnProperty.call(params, 'content')) {
|
|
3580
|
-
params.payload = params.content;
|
|
3581
|
-
delete params.content;
|
|
3582
|
-
}
|
|
3583
|
-
const payload = params.payload;
|
|
3584
|
-
if (isJsonObject(payload) && !Object.prototype.hasOwnProperty.call(payload, 'type') && typeof payload.text === 'string') {
|
|
3585
|
-
params.payload = { type: 'text', ...payload };
|
|
3586
|
-
}
|
|
3587
|
-
}
|
|
3588
2046
|
_validateMessageRecipient(toAid) {
|
|
3589
|
-
|
|
3590
|
-
throw new ValidationError('message.send receiver cannot be group.{issuer}; use group.send instead');
|
|
3591
|
-
}
|
|
2047
|
+
this._rpcPipeline.validateMessageRecipient(toAid);
|
|
3592
2048
|
}
|
|
3593
2049
|
_validateOutboundCall(method, params) {
|
|
3594
|
-
|
|
3595
|
-
this._validateMessageRecipient(params.to);
|
|
3596
|
-
if ('persist' in params) {
|
|
3597
|
-
throw new ValidationError("message.send no longer accepts 'persist'; configure delivery_mode during connect");
|
|
3598
|
-
}
|
|
3599
|
-
if ('delivery_mode' in params || 'queue_routing' in params || 'affinity_ttl_ms' in params) {
|
|
3600
|
-
throw new ValidationError('message.send does not accept delivery_mode; configure delivery_mode during connect');
|
|
3601
|
-
}
|
|
3602
|
-
}
|
|
3603
|
-
if (method === 'group.send') {
|
|
3604
|
-
if ('persist' in params) {
|
|
3605
|
-
throw new ValidationError("group.send does not accept 'persist'; group messages are always fanout");
|
|
3606
|
-
}
|
|
3607
|
-
if ('delivery_mode' in params || 'queue_routing' in params || 'affinity_ttl_ms' in params) {
|
|
3608
|
-
throw new ValidationError('group.send does not accept delivery_mode; group messages are always fanout');
|
|
3609
|
-
}
|
|
3610
|
-
}
|
|
3611
|
-
if (method === 'group.thought.put' || method === 'group.thought.get'
|
|
3612
|
-
|| method === 'message.thought.put' || method === 'message.thought.get') {
|
|
3613
|
-
const context = isJsonObject(params.context) ? params.context : null;
|
|
3614
|
-
const contextType = String(context?.type ?? '').trim();
|
|
3615
|
-
const contextId = String(context?.id ?? '').trim();
|
|
3616
|
-
const hasContext = contextType.length > 0 && contextId.length > 0;
|
|
3617
|
-
if (!hasContext) {
|
|
3618
|
-
throw new ValidationError(`${method} requires context.type + context.id`);
|
|
3619
|
-
}
|
|
3620
|
-
}
|
|
3621
|
-
if (method === 'group.thought.get' && !String(params.sender_aid ?? '').trim()) {
|
|
3622
|
-
throw new ValidationError('group.thought.get requires sender_aid');
|
|
3623
|
-
}
|
|
3624
|
-
if (method === 'message.thought.put') {
|
|
3625
|
-
this._validateMessageRecipient(params.to);
|
|
3626
|
-
if (!String(params.to ?? '').trim()) {
|
|
3627
|
-
throw new ValidationError('message.thought.put requires to');
|
|
3628
|
-
}
|
|
3629
|
-
}
|
|
3630
|
-
if (method === 'message.thought.get' && !String(params.sender_aid ?? '').trim()) {
|
|
3631
|
-
throw new ValidationError('message.thought.get requires sender_aid');
|
|
3632
|
-
}
|
|
3633
|
-
}
|
|
3634
|
-
_currentMessageDeliveryMode() {
|
|
3635
|
-
return { ...this._connectDeliveryMode };
|
|
2050
|
+
this._rpcPipeline.validateOutboundCall(method, params);
|
|
3636
2051
|
}
|
|
3637
2052
|
_injectMessageCursorContext(method, params) {
|
|
3638
|
-
|
|
3639
|
-
return;
|
|
3640
|
-
}
|
|
3641
|
-
if ('device_id' in params && String(params.device_id ?? '').trim() !== this._deviceId) {
|
|
3642
|
-
throw new ValidationError('message.pull/message.ack device_id must match the current client instance');
|
|
3643
|
-
}
|
|
3644
|
-
const slotId = normalizeSlotId(params.slot_id ?? this._slotId, this._slotId);
|
|
3645
|
-
if (slotIsolationKey(slotId) !== slotIsolationKey(this._slotId)) {
|
|
3646
|
-
throw new ValidationError('message.pull/message.ack slot_id must match the current client instance');
|
|
3647
|
-
}
|
|
3648
|
-
params.device_id = this._deviceId;
|
|
3649
|
-
params.slot_id = this._slotId;
|
|
2053
|
+
this._rpcPipeline.injectMessageCursorContext(method, params);
|
|
3650
2054
|
}
|
|
3651
2055
|
// ── 内部:断线重连 ────────────────────────────────
|
|
3652
2056
|
/** 不重连 close code 集合:认证失败/权限错误/被踢等,重连无意义 */
|
|
@@ -3676,7 +2080,7 @@ export class AUNClient {
|
|
|
3676
2080
|
async _handleTransportDisconnect(error, closeCode) {
|
|
3677
2081
|
if (this._closing || this._state === 'closed')
|
|
3678
2082
|
return;
|
|
3679
|
-
this._state = '
|
|
2083
|
+
this._state = 'standby';
|
|
3680
2084
|
// 先停止后台任务,避免心跳/token刷新在重连期间继续触发
|
|
3681
2085
|
this._stopBackgroundTasks();
|
|
3682
2086
|
await this._dispatcher.publish('state_change', {
|
|
@@ -3808,76 +2212,6 @@ export class AUNClient {
|
|
|
3808
2212
|
this._reconnectActive = false;
|
|
3809
2213
|
this._reconnectAbort = null;
|
|
3810
2214
|
}
|
|
3811
|
-
// ── Named Group(命名群)高层 API ────────────────────────────
|
|
3812
|
-
/**
|
|
3813
|
-
* 创建命名群:群/P2P 私钥由 V2 数据库存储,不写入 AID 身份私钥存储。
|
|
3814
|
-
*/
|
|
3815
|
-
async createNamedGroup(groupName, opts = {}) {
|
|
3816
|
-
const tStart = Date.now();
|
|
3817
|
-
this._clientLog.debug(`createNamedGroup enter: name=${groupName}`);
|
|
3818
|
-
try {
|
|
3819
|
-
const cp = new CryptoProvider();
|
|
3820
|
-
const identity = await cp.generateIdentity();
|
|
3821
|
-
const params = {};
|
|
3822
|
-
for (const [k, v] of Object.entries(opts)) {
|
|
3823
|
-
params[k] = v;
|
|
3824
|
-
}
|
|
3825
|
-
params.group_name = groupName;
|
|
3826
|
-
params.public_key = identity.public_key_der_b64;
|
|
3827
|
-
params.curve = 'P-256';
|
|
3828
|
-
const result = await this.call('group.create', params);
|
|
3829
|
-
const groupInfo = result?.group;
|
|
3830
|
-
const aidCert = result?.aid_cert;
|
|
3831
|
-
const groupAid = String(groupInfo?.group_aid ?? '');
|
|
3832
|
-
if (groupAid && aidCert) {
|
|
3833
|
-
await this._saveGroupIdentityToV2(groupAid, identity);
|
|
3834
|
-
const certPem = String(aidCert.cert ?? '');
|
|
3835
|
-
if (certPem) {
|
|
3836
|
-
await this._tokenStore.saveCert(groupAid, certPem);
|
|
3837
|
-
}
|
|
3838
|
-
}
|
|
3839
|
-
this._clientLog.debug(`createNamedGroup exit: elapsed=${Date.now() - tStart}ms group_aid=${groupAid}`);
|
|
3840
|
-
return result;
|
|
3841
|
-
}
|
|
3842
|
-
catch (err) {
|
|
3843
|
-
this._clientLog.debug(`createNamedGroup exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
3844
|
-
throw err;
|
|
3845
|
-
}
|
|
3846
|
-
}
|
|
3847
|
-
/**
|
|
3848
|
-
* 为已有普通群绑定命名 AID(升级为命名群)。
|
|
3849
|
-
*/
|
|
3850
|
-
async bindGroupAid(groupId, groupName) {
|
|
3851
|
-
const tStart = Date.now();
|
|
3852
|
-
this._clientLog.debug(`bindGroupAid enter: group_id=${groupId} name=${groupName}`);
|
|
3853
|
-
try {
|
|
3854
|
-
const cp = new CryptoProvider();
|
|
3855
|
-
const identity = await cp.generateIdentity();
|
|
3856
|
-
const params = {
|
|
3857
|
-
group_id: groupId,
|
|
3858
|
-
group_name: groupName,
|
|
3859
|
-
public_key: identity.public_key_der_b64,
|
|
3860
|
-
curve: 'P-256',
|
|
3861
|
-
};
|
|
3862
|
-
const result = await this.call('group.bind_aid', params);
|
|
3863
|
-
const groupInfo = result?.group;
|
|
3864
|
-
const aidCert = result?.aid_cert;
|
|
3865
|
-
const groupAid = String(groupInfo?.group_aid ?? '');
|
|
3866
|
-
if (groupAid && aidCert) {
|
|
3867
|
-
await this._saveGroupIdentityToV2(groupAid, identity);
|
|
3868
|
-
const certPem = String(aidCert.cert ?? '');
|
|
3869
|
-
if (certPem) {
|
|
3870
|
-
await this._tokenStore.saveCert(groupAid, certPem);
|
|
3871
|
-
}
|
|
3872
|
-
}
|
|
3873
|
-
this._clientLog.debug(`bindGroupAid exit: elapsed=${Date.now() - tStart}ms group_aid=${groupAid}`);
|
|
3874
|
-
return result;
|
|
3875
|
-
}
|
|
3876
|
-
catch (err) {
|
|
3877
|
-
this._clientLog.debug(`bindGroupAid exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
3878
|
-
throw err;
|
|
3879
|
-
}
|
|
3880
|
-
}
|
|
3881
2215
|
/** 判断是否应重试重连 */
|
|
3882
2216
|
_shouldRetryReconnect(error) {
|
|
3883
2217
|
if (error instanceof AuthError) {
|
|
@@ -3901,113 +2235,7 @@ export class AUNClient {
|
|
|
3901
2235
|
// ── 内部:工具方法 ────────────────────────────────
|
|
3902
2236
|
/** 从 keystore 恢复 SeqTracker 状态(真正可 await,确保在 transport.connect 前完成) */
|
|
3903
2237
|
async _restoreSeqTrackerState() {
|
|
3904
|
-
|
|
3905
|
-
return;
|
|
3906
|
-
const context = this._seqTrackerContext;
|
|
3907
|
-
if (!context)
|
|
3908
|
-
return;
|
|
3909
|
-
const aid = this._aid;
|
|
3910
|
-
const deviceId = this._deviceId;
|
|
3911
|
-
const slotId = this._slotId;
|
|
3912
|
-
try {
|
|
3913
|
-
// 优先从 seq_tracker 表按行读取
|
|
3914
|
-
const loadAll = this._tokenStore.loadAllSeqs?.bind(this._tokenStore);
|
|
3915
|
-
if (typeof loadAll === 'function') {
|
|
3916
|
-
let state = await loadAll(aid, deviceId, slotId);
|
|
3917
|
-
if (this._seqTrackerContext !== context)
|
|
3918
|
-
return;
|
|
3919
|
-
if (state && typeof state === 'object' && Object.keys(state).length > 0) {
|
|
3920
|
-
state = await this._migrateSeqStateGroupIds(state);
|
|
3921
|
-
this._seqTracker.restoreState(state);
|
|
3922
|
-
}
|
|
3923
|
-
return;
|
|
3924
|
-
}
|
|
3925
|
-
// fallback: 从旧 instance_state JSON blob 恢复
|
|
3926
|
-
const loader = this._tokenStore.loadInstanceState?.bind(this._tokenStore);
|
|
3927
|
-
if (typeof loader !== 'function')
|
|
3928
|
-
return;
|
|
3929
|
-
const stateHolder = await loader(aid, deviceId, slotId);
|
|
3930
|
-
if (this._seqTrackerContext !== context)
|
|
3931
|
-
return;
|
|
3932
|
-
if (stateHolder && typeof stateHolder === 'object') {
|
|
3933
|
-
const state = stateHolder.seq_tracker_state;
|
|
3934
|
-
if (isJsonObject(state)) {
|
|
3935
|
-
const migrated = await this._migrateSeqStateGroupIds(state);
|
|
3936
|
-
this._seqTracker.restoreState(migrated);
|
|
3937
|
-
}
|
|
3938
|
-
}
|
|
3939
|
-
}
|
|
3940
|
-
catch (exc) {
|
|
3941
|
-
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
3942
|
-
phase: 'restore',
|
|
3943
|
-
aid,
|
|
3944
|
-
device_id: deviceId,
|
|
3945
|
-
slot_id: slotId,
|
|
3946
|
-
error: String(exc),
|
|
3947
|
-
}).catch(() => { });
|
|
3948
|
-
}
|
|
3949
|
-
}
|
|
3950
|
-
/**
|
|
3951
|
-
* 把 seq_tracker state 里 group_event:/group_msg: 前缀的老/污染 group_id 归一化。
|
|
3952
|
-
* 冲突取 max;同时落盘删除老 ns、写入新 ns。
|
|
3953
|
-
*/
|
|
3954
|
-
async _migrateSeqStateGroupIds(state) {
|
|
3955
|
-
if (!state || Object.keys(state).length === 0)
|
|
3956
|
-
return state;
|
|
3957
|
-
const renameMap = {};
|
|
3958
|
-
for (const ns of Object.keys(state)) {
|
|
3959
|
-
for (const prefix of ['group_event:', 'group_msg:']) {
|
|
3960
|
-
if (ns.startsWith(prefix)) {
|
|
3961
|
-
const oldGid = ns.slice(prefix.length);
|
|
3962
|
-
const newGid = normalizeGroupId(oldGid);
|
|
3963
|
-
if (newGid && newGid !== oldGid) {
|
|
3964
|
-
renameMap[ns] = `${prefix}${newGid}`;
|
|
3965
|
-
}
|
|
3966
|
-
break;
|
|
3967
|
-
}
|
|
3968
|
-
}
|
|
3969
|
-
}
|
|
3970
|
-
if (Object.keys(renameMap).length === 0)
|
|
3971
|
-
return state;
|
|
3972
|
-
const newState = { ...state };
|
|
3973
|
-
for (const [oldNs, newNs] of Object.entries(renameMap)) {
|
|
3974
|
-
const oldVal = Number(newState[oldNs] ?? 0);
|
|
3975
|
-
const curVal = Number(newState[newNs] ?? 0);
|
|
3976
|
-
delete newState[oldNs];
|
|
3977
|
-
newState[newNs] = Math.max(oldVal, curVal);
|
|
3978
|
-
}
|
|
3979
|
-
if (!this._aid)
|
|
3980
|
-
return newState;
|
|
3981
|
-
const aid = this._aid;
|
|
3982
|
-
const deviceId = this._deviceId;
|
|
3983
|
-
const slotId = this._slotId;
|
|
3984
|
-
const saver = this._tokenStore.saveSeq?.bind(this._tokenStore);
|
|
3985
|
-
const deleter = this._tokenStore.deleteSeq?.bind(this._tokenStore);
|
|
3986
|
-
if (typeof saver === 'function') {
|
|
3987
|
-
for (const [oldNs, newNs] of Object.entries(renameMap)) {
|
|
3988
|
-
if (typeof deleter === 'function') {
|
|
3989
|
-
try {
|
|
3990
|
-
await deleter(aid, deviceId, slotId, oldNs);
|
|
3991
|
-
}
|
|
3992
|
-
catch (e) {
|
|
3993
|
-
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
3994
|
-
phase: 'migrate_delete', aid, device_id: deviceId, slot_id: slotId,
|
|
3995
|
-
ns: oldNs, error: String(e),
|
|
3996
|
-
}).catch(() => { });
|
|
3997
|
-
}
|
|
3998
|
-
}
|
|
3999
|
-
try {
|
|
4000
|
-
await saver(aid, deviceId, slotId, newNs, newState[newNs]);
|
|
4001
|
-
}
|
|
4002
|
-
catch (e) {
|
|
4003
|
-
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
4004
|
-
phase: 'migrate_save', aid, device_id: deviceId, slot_id: slotId,
|
|
4005
|
-
ns: newNs, error: String(e),
|
|
4006
|
-
}).catch(() => { });
|
|
4007
|
-
}
|
|
4008
|
-
}
|
|
4009
|
-
}
|
|
4010
|
-
return newState;
|
|
2238
|
+
return this._delivery.restoreSeqTrackerState();
|
|
4011
2239
|
}
|
|
4012
2240
|
_currentSeqTrackerContext() {
|
|
4013
2241
|
if (!this._aid)
|
|
@@ -4015,6 +2243,7 @@ export class AUNClient {
|
|
|
4015
2243
|
return JSON.stringify([this._aid, this._deviceId, this._slotId]);
|
|
4016
2244
|
}
|
|
4017
2245
|
_resetSeqTrackingState() {
|
|
2246
|
+
this._resetV2IdentityRuntime();
|
|
4018
2247
|
this._seqTracker = new SeqTracker();
|
|
4019
2248
|
this._seqTrackerContext = null;
|
|
4020
2249
|
this._gapFillDone.clear();
|
|
@@ -4023,6 +2252,12 @@ export class AUNClient {
|
|
|
4023
2252
|
this._v2SenderIKPending.clear();
|
|
4024
2253
|
this._v2SenderIKFetching.clear();
|
|
4025
2254
|
this._groupSynced.clear();
|
|
2255
|
+
this._onlineUnreadHintQueue.clear();
|
|
2256
|
+
if (this._onlineUnreadHintTimer) {
|
|
2257
|
+
clearTimeout(this._onlineUnreadHintTimer);
|
|
2258
|
+
this._onlineUnreadHintTimer = null;
|
|
2259
|
+
}
|
|
2260
|
+
this._onlineUnreadHintDrainActive = false;
|
|
4026
2261
|
}
|
|
4027
2262
|
_refreshSeqTrackerContext() {
|
|
4028
2263
|
const nextContext = this._currentSeqTrackerContext();
|
|
@@ -4035,83 +2270,26 @@ export class AUNClient {
|
|
|
4035
2270
|
this._v2SenderIKPending.clear();
|
|
4036
2271
|
this._v2SenderIKFetching.clear();
|
|
4037
2272
|
this._groupSynced.clear();
|
|
2273
|
+
this._onlineUnreadHintQueue.clear();
|
|
2274
|
+
if (this._onlineUnreadHintTimer) {
|
|
2275
|
+
clearTimeout(this._onlineUnreadHintTimer);
|
|
2276
|
+
this._onlineUnreadHintTimer = null;
|
|
2277
|
+
}
|
|
2278
|
+
this._onlineUnreadHintDrainActive = false;
|
|
4038
2279
|
this._seqTrackerContext = nextContext;
|
|
4039
2280
|
}
|
|
4040
2281
|
/** 将 SeqTracker 状态保存到 keystore */
|
|
4041
2282
|
_saveSeqTrackerState() {
|
|
4042
|
-
|
|
4043
|
-
return;
|
|
4044
|
-
const state = this._seqTracker.exportState();
|
|
4045
|
-
if (Object.keys(state).length === 0)
|
|
4046
|
-
return;
|
|
4047
|
-
try {
|
|
4048
|
-
// 优先按行写入 seq_tracker 表
|
|
4049
|
-
const saveFn = this._tokenStore.saveSeq?.bind(this._tokenStore);
|
|
4050
|
-
if (typeof saveFn === 'function') {
|
|
4051
|
-
for (const [ns, seq] of Object.entries(state)) {
|
|
4052
|
-
saveFn(this._aid, this._deviceId, this._slotId, ns, seq).catch((exc) => {
|
|
4053
|
-
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
4054
|
-
phase: 'save',
|
|
4055
|
-
aid: this._aid,
|
|
4056
|
-
device_id: this._deviceId,
|
|
4057
|
-
slot_id: this._slotId,
|
|
4058
|
-
error: String(exc),
|
|
4059
|
-
}).catch(() => { });
|
|
4060
|
-
});
|
|
4061
|
-
}
|
|
4062
|
-
return;
|
|
4063
|
-
}
|
|
4064
|
-
// fallback: 旧版 updateInstanceState JSON blob
|
|
4065
|
-
if (typeof this._tokenStore.updateInstanceState === 'function') {
|
|
4066
|
-
this._tokenStore.updateInstanceState(this._aid, this._deviceId, this._slotId, (current) => {
|
|
4067
|
-
current.seq_tracker_state = state;
|
|
4068
|
-
return current;
|
|
4069
|
-
}).catch((exc) => {
|
|
4070
|
-
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
4071
|
-
phase: 'save',
|
|
4072
|
-
aid: this._aid,
|
|
4073
|
-
device_id: this._deviceId,
|
|
4074
|
-
slot_id: this._slotId,
|
|
4075
|
-
error: String(exc),
|
|
4076
|
-
}).catch(() => { });
|
|
4077
|
-
});
|
|
4078
|
-
}
|
|
4079
|
-
}
|
|
4080
|
-
catch (exc) {
|
|
4081
|
-
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
4082
|
-
phase: 'save',
|
|
4083
|
-
aid: this._aid,
|
|
4084
|
-
device_id: this._deviceId,
|
|
4085
|
-
slot_id: this._slotId,
|
|
4086
|
-
error: String(exc),
|
|
4087
|
-
}).catch(() => { });
|
|
4088
|
-
}
|
|
2283
|
+
return this._delivery.saveSeqTrackerState();
|
|
4089
2284
|
}
|
|
4090
2285
|
_persistRepairedSeq(ns) {
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
});
|
|
4099
|
-
return;
|
|
4100
|
-
}
|
|
4101
|
-
const deleteSeq = this._tokenStore.deleteSeq;
|
|
4102
|
-
if (seq <= 0 && typeof deleteSeq === 'function') {
|
|
4103
|
-
deleteSeq.call(this._tokenStore, this._aid, this._deviceId, this._slotId, ns).catch((exc) => {
|
|
4104
|
-
this._clientLog.debug(`delete repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
|
|
4105
|
-
});
|
|
4106
|
-
return;
|
|
4107
|
-
}
|
|
4108
|
-
if (seq > 0) {
|
|
4109
|
-
this._saveSeqTrackerState();
|
|
4110
|
-
}
|
|
4111
|
-
}
|
|
4112
|
-
catch (exc) {
|
|
4113
|
-
this._clientLog.debug(`persist repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
|
|
4114
|
-
}
|
|
2286
|
+
return this._delivery.persistRepairedSeq(ns);
|
|
2287
|
+
}
|
|
2288
|
+
_clampAckSeq(method, field, ns, seq) {
|
|
2289
|
+
return this._delivery.clampAckSeq(method, field, ns, seq);
|
|
2290
|
+
}
|
|
2291
|
+
_clampAckParams(method, params) {
|
|
2292
|
+
return this._delivery.clampAckParams(method, params);
|
|
4115
2293
|
}
|
|
4116
2294
|
_repairPushContiguousBound(ns, pushSeq, hasPayload, label) {
|
|
4117
2295
|
if (!ns || !Number.isFinite(pushSeq) || pushSeq <= 0) {
|
|
@@ -4129,7 +2307,7 @@ export class AUNClient {
|
|
|
4129
2307
|
return repaired;
|
|
4130
2308
|
}
|
|
4131
2309
|
async _ensureV2SessionReady(method, errorMessage) {
|
|
4132
|
-
if (!this.
|
|
2310
|
+
if (!this._v2SessionMatchesIdentity()) {
|
|
4133
2311
|
if (!this._v2SessionInitInFlight) {
|
|
4134
2312
|
this._v2SessionInitInFlight = this._initV2Session()
|
|
4135
2313
|
.finally(() => {
|
|
@@ -4138,10 +2316,13 @@ export class AUNClient {
|
|
|
4138
2316
|
}
|
|
4139
2317
|
await this._v2SessionInitInFlight;
|
|
4140
2318
|
}
|
|
4141
|
-
if (!this.
|
|
2319
|
+
if (!this._v2SessionMatchesIdentity()) {
|
|
4142
2320
|
throw new StateError(errorMessage ?? `V2 session not initialized; encrypted ${method} requires E2EE V2`);
|
|
4143
2321
|
}
|
|
4144
2322
|
}
|
|
2323
|
+
_v2CallFn() {
|
|
2324
|
+
return async (method, params) => this.call(method, params);
|
|
2325
|
+
}
|
|
4145
2326
|
// ── V2 E2EE API(async,与 Python `client.py` `_init_v2_session` / `send_v2` / `pull_v2` / `ack_v2` 对齐) ──
|
|
4146
2327
|
/**
|
|
4147
2328
|
* 初始化 V2 session:从 AID PEM 私钥提取 raw scalar + DER 公钥,
|
|
@@ -4150,62 +2331,7 @@ export class AUNClient {
|
|
|
4150
2331
|
* connect 成功后自动调用,可幂等手动调用。
|
|
4151
2332
|
*/
|
|
4152
2333
|
async _initV2Session() {
|
|
4153
|
-
|
|
4154
|
-
return;
|
|
4155
|
-
// 私钥来自当前 AID 值对象,AUNClient 不从持久化存储读取私钥。
|
|
4156
|
-
const currentAid = this._currentAid;
|
|
4157
|
-
if (!currentAid?.privateKeyPem) {
|
|
4158
|
-
this._clientLog.warn('V2 session init skipped: no AID private key');
|
|
4159
|
-
return;
|
|
4160
|
-
}
|
|
4161
|
-
if (this._v2Session)
|
|
4162
|
-
return;
|
|
4163
|
-
// 1. PEM → DER PKCS8(去掉 header/footer 后 base64 解码)
|
|
4164
|
-
const pem = currentAid.privateKeyPem.trim();
|
|
4165
|
-
const pemBody = pem
|
|
4166
|
-
.replace(/-----BEGIN [^-]+-----/g, '')
|
|
4167
|
-
.replace(/-----END [^-]+-----/g, '')
|
|
4168
|
-
.replace(/\s+/g, '');
|
|
4169
|
-
const pkcs8Der = _v2B64ToBytes(pemBody);
|
|
4170
|
-
// 2. 用 WebCrypto 导入 PKCS8 → 导出 jwk → 从 d 拿到 raw scalar
|
|
4171
|
-
const privKey = await crypto.subtle.importKey('pkcs8', pkcs8Der.slice().buffer, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
|
|
4172
|
-
const jwk = await crypto.subtle.exportKey('jwk', privKey);
|
|
4173
|
-
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') {
|
|
4174
|
-
throw new Error('AID private key must be EC P-256');
|
|
4175
|
-
}
|
|
4176
|
-
if (!jwk.d || !jwk.x || !jwk.y) {
|
|
4177
|
-
throw new Error('AID private key jwk missing d/x/y');
|
|
4178
|
-
}
|
|
4179
|
-
const aidPriv = _v2LeftPad32(_v2B64uToBytes(jwk.d));
|
|
4180
|
-
// 3. 从 jwk 公钥导出 SPKI DER
|
|
4181
|
-
const pubKey = await crypto.subtle.importKey('jwk', { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y, ext: true }, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']);
|
|
4182
|
-
const aidPubDer = new Uint8Array(await crypto.subtle.exportKey('spki', pubKey));
|
|
4183
|
-
// 4. 打开 V2 KeyStore(IndexedDB)
|
|
4184
|
-
if (!this._v2KeyStore) {
|
|
4185
|
-
this._v2KeyStore = await V2KeyStore.open();
|
|
4186
|
-
}
|
|
4187
|
-
this._v2Session = new V2Session(this._v2KeyStore, this._deviceId, this._aid, aidPriv, aidPubDer);
|
|
4188
|
-
const callFn = async (method, params) => {
|
|
4189
|
-
return this.call(method, params);
|
|
4190
|
-
};
|
|
4191
|
-
await this._v2Session.ensureRegistered(callFn);
|
|
4192
|
-
this._clientLog.debug(`V2 session initialized aid=${this._aid} device=${this._deviceId}`);
|
|
4193
|
-
// 上线时自动确认 pending state proposals
|
|
4194
|
-
this._safeAsync(this._v2AutoConfirmPendingProposals());
|
|
4195
|
-
}
|
|
4196
|
-
_v2StoreDeviceId() {
|
|
4197
|
-
return `aid:${encodeURIComponent(String(this._aid ?? ''))}|device:${encodeURIComponent(String(this._deviceId ?? ''))}`;
|
|
4198
|
-
}
|
|
4199
|
-
async _saveGroupIdentityToV2(groupAid, identity) {
|
|
4200
|
-
const privateKeyPem = String(identity.private_key_pem ?? '').trim();
|
|
4201
|
-
const publicKeyDerB64 = String(identity.public_key_der_b64 ?? '').trim();
|
|
4202
|
-
if (!groupAid || !privateKeyPem || !publicKeyDerB64) {
|
|
4203
|
-
throw new StateError('group identity is incomplete');
|
|
4204
|
-
}
|
|
4205
|
-
if (!this._v2KeyStore) {
|
|
4206
|
-
this._v2KeyStore = await V2KeyStore.open();
|
|
4207
|
-
}
|
|
4208
|
-
await this._v2KeyStore.saveGroupIdentity(this._v2StoreDeviceId(), groupAid, privateKeyPem, base64ToUint8(publicKeyDerB64));
|
|
2334
|
+
return this._v2E2EE.initV2Session();
|
|
4209
2335
|
}
|
|
4210
2336
|
async _v2TrustedIKPubDer(aid) {
|
|
4211
2337
|
const normalizedAid = String(aid ?? '').trim();
|
|
@@ -4251,7 +2377,7 @@ export class AUNClient {
|
|
|
4251
2377
|
if (!args.spkPkDer || args.spkPkDer.length === 0) {
|
|
4252
2378
|
throw new E2EEError(`spk_public_key_missing: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
4253
2379
|
}
|
|
4254
|
-
const spkHash = bytesToHex(new Uint8Array(await crypto.subtle.digest('SHA-256', args.spkPkDer
|
|
2380
|
+
const spkHash = bytesToHex(new Uint8Array(await crypto.subtle.digest('SHA-256', exactArrayBuffer(args.spkPkDer))));
|
|
4255
2381
|
const expectedSpkId = `sha256:${spkHash.substring(0, 16)}`;
|
|
4256
2382
|
if (spkId !== expectedSpkId) {
|
|
4257
2383
|
throw new E2EEError(`spk_id_mismatch: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId} expected=${expectedSpkId}`);
|
|
@@ -4305,34 +2431,8 @@ export class AUNClient {
|
|
|
4305
2431
|
spkId: String(args.dev.spk_id ?? '').trim(),
|
|
4306
2432
|
};
|
|
4307
2433
|
}
|
|
4308
|
-
async _getV2SenderPubDer(fromAid, senderDeviceId) {
|
|
4309
|
-
|
|
4310
|
-
if (!session || !fromAid)
|
|
4311
|
-
return null;
|
|
4312
|
-
let senderPubDer = session.getPeerIK(fromAid, senderDeviceId);
|
|
4313
|
-
if (senderPubDer)
|
|
4314
|
-
return senderPubDer;
|
|
4315
|
-
try {
|
|
4316
|
-
const certPem = await this._fetchPeerCert(fromAid, undefined, 3000);
|
|
4317
|
-
const pubKey = await importCertPublicKeyEcdsa(certPem);
|
|
4318
|
-
senderPubDer = new Uint8Array(await crypto.subtle.exportKey('spki', pubKey));
|
|
4319
|
-
session.cachePeerIK(fromAid, senderDeviceId, senderPubDer);
|
|
4320
|
-
this._clientLog.debug(`V2 decrypt: sender IK fallback from PKI cert for ${fromAid}`);
|
|
4321
|
-
return senderPubDer;
|
|
4322
|
-
}
|
|
4323
|
-
catch (exc) {
|
|
4324
|
-
this._clientLog.warn(`V2 decrypt: PKI cert sender IK fallback failed for ${fromAid}: ${String(formatCaughtError(exc))}`);
|
|
4325
|
-
return null;
|
|
4326
|
-
}
|
|
4327
|
-
}
|
|
4328
|
-
_v2PendingSenderIKMessageKey(msg, groupId) {
|
|
4329
|
-
const messageId = String(msg.message_id ?? '').trim();
|
|
4330
|
-
const seq = String(msg.seq ?? '').trim();
|
|
4331
|
-
const prefix = groupId ? `group:${groupId}` : `p2p:${this._aid ?? ''}`;
|
|
4332
|
-
return `${prefix}:${messageId || seq || Math.random().toString(36).slice(2)}`;
|
|
4333
|
-
}
|
|
4334
|
-
_v2PendingSenderIKFetchKey(fromAid, senderDeviceId, groupId) {
|
|
4335
|
-
return `${fromAid}#${senderDeviceId}#${groupId || ''}`;
|
|
2434
|
+
async _getV2SenderPubDer(fromAid, senderDeviceId, certFingerprint) {
|
|
2435
|
+
return await this._v2E2EE.getV2SenderPubDer(fromAid, senderDeviceId, certFingerprint);
|
|
4336
2436
|
}
|
|
4337
2437
|
_cacheV2PeerIKFromDevice(dev, fallbackAid = '') {
|
|
4338
2438
|
const session = this._v2Session;
|
|
@@ -4352,94 +2452,10 @@ export class AUNClient {
|
|
|
4352
2452
|
}
|
|
4353
2453
|
}
|
|
4354
2454
|
_scheduleV2SenderIKPending(args) {
|
|
4355
|
-
|
|
4356
|
-
if (!fromAid)
|
|
4357
|
-
return;
|
|
4358
|
-
const senderDeviceId = String(args.senderDeviceId ?? '');
|
|
4359
|
-
const groupId = String(args.groupId ?? '').trim();
|
|
4360
|
-
const messageKey = this._v2PendingSenderIKMessageKey(args.msg, groupId);
|
|
4361
|
-
this._v2SenderIKPending.set(messageKey, {
|
|
4362
|
-
msg: { ...args.msg },
|
|
4363
|
-
fromAid,
|
|
4364
|
-
senderDeviceId,
|
|
4365
|
-
groupId,
|
|
4366
|
-
createdAt: Date.now(),
|
|
4367
|
-
});
|
|
4368
|
-
this._clientLog.debug(`V2 decrypt pending sender IK: key=${messageKey} from=${fromAid} device=${senderDeviceId || '-'} group=${groupId || '<p2p>'} pending=${this._v2SenderIKPending.size}`);
|
|
4369
|
-
this._scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupId);
|
|
4370
|
-
}
|
|
4371
|
-
_scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupId) {
|
|
4372
|
-
const fetchKey = this._v2PendingSenderIKFetchKey(fromAid, senderDeviceId, groupId);
|
|
4373
|
-
if (!fromAid || this._v2SenderIKFetching.has(fetchKey))
|
|
4374
|
-
return;
|
|
4375
|
-
this._v2SenderIKFetching.add(fetchKey);
|
|
4376
|
-
this._safeAsync(this._resolveV2SenderIKPending(fromAid, senderDeviceId, groupId, fetchKey));
|
|
2455
|
+
return this._v2E2EE.scheduleSenderIKPending(args);
|
|
4377
2456
|
}
|
|
4378
2457
|
async _resolveV2SenderIKPending(fromAid, senderDeviceId, groupId, fetchKey) {
|
|
4379
|
-
|
|
4380
|
-
const session = this._v2Session;
|
|
4381
|
-
if (session && fromAid) {
|
|
4382
|
-
try {
|
|
4383
|
-
const bs = await this.call('message.v2.bootstrap', {
|
|
4384
|
-
peer_aid: fromAid,
|
|
4385
|
-
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
4386
|
-
});
|
|
4387
|
-
const peers = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
|
|
4388
|
-
for (const dev of peers)
|
|
4389
|
-
this._cacheV2PeerIKFromDevice(dev, fromAid);
|
|
4390
|
-
}
|
|
4391
|
-
catch (exc) {
|
|
4392
|
-
this._clientLog.warn(`V2 sender IK pending bootstrap failed peer=${fromAid}: ${String(formatCaughtError(exc))}`);
|
|
4393
|
-
}
|
|
4394
|
-
if (groupId) {
|
|
4395
|
-
try {
|
|
4396
|
-
const gbs = await this.call('group.v2.bootstrap', {
|
|
4397
|
-
group_id: groupId,
|
|
4398
|
-
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
4399
|
-
});
|
|
4400
|
-
const devices = (Array.isArray(gbs?.devices) ? gbs.devices : []);
|
|
4401
|
-
const audit = (Array.isArray(gbs?.audit_recipients) ? gbs.audit_recipients : []);
|
|
4402
|
-
for (const dev of devices)
|
|
4403
|
-
this._cacheV2PeerIKFromDevice(dev);
|
|
4404
|
-
for (const dev of audit)
|
|
4405
|
-
this._cacheV2PeerIKFromDevice(dev);
|
|
4406
|
-
}
|
|
4407
|
-
catch (exc) {
|
|
4408
|
-
this._clientLog.warn(`V2 sender IK pending group bootstrap failed group=${groupId}: ${String(formatCaughtError(exc))}`);
|
|
4409
|
-
}
|
|
4410
|
-
}
|
|
4411
|
-
if (!session.getPeerIK(fromAid, senderDeviceId)) {
|
|
4412
|
-
await this._getV2SenderPubDer(fromAid, senderDeviceId);
|
|
4413
|
-
}
|
|
4414
|
-
}
|
|
4415
|
-
const pendingItems = [...this._v2SenderIKPending.entries()].filter(([, entry]) => entry.fromAid === fromAid && entry.senderDeviceId === senderDeviceId && entry.groupId === groupId);
|
|
4416
|
-
for (const [key, entry] of pendingItems) {
|
|
4417
|
-
let plaintext = null;
|
|
4418
|
-
try {
|
|
4419
|
-
plaintext = await this._decryptV2Message(entry.msg, false);
|
|
4420
|
-
}
|
|
4421
|
-
catch (exc) {
|
|
4422
|
-
this._clientLog.warn(`V2 sender IK pending retry raised: key=${key} err=${String(formatCaughtError(exc))}`);
|
|
4423
|
-
}
|
|
4424
|
-
this._v2SenderIKPending.delete(key);
|
|
4425
|
-
if (plaintext === null) {
|
|
4426
|
-
this._clientLog.debug(`V2 sender IK pending retry failed: key=${key}`);
|
|
4427
|
-
continue;
|
|
4428
|
-
}
|
|
4429
|
-
const seq = Number(entry.msg.seq ?? 0);
|
|
4430
|
-
if (entry.groupId) {
|
|
4431
|
-
plaintext.group_id = entry.groupId;
|
|
4432
|
-
await this._publishPulledMessage('group.message_created', `group:${entry.groupId}`, seq, plaintext);
|
|
4433
|
-
}
|
|
4434
|
-
else {
|
|
4435
|
-
await this._publishPulledMessage('message.received', `p2p:${this._aid ?? ''}`, seq, plaintext);
|
|
4436
|
-
}
|
|
4437
|
-
this._clientLog.debug(`V2 sender IK pending retry delivered: key=${key}`);
|
|
4438
|
-
}
|
|
4439
|
-
}
|
|
4440
|
-
finally {
|
|
4441
|
-
this._v2SenderIKFetching.delete(fetchKey);
|
|
4442
|
-
}
|
|
2458
|
+
return await this._v2E2EE.resolveSenderIKPending(fromAid, senderDeviceId, groupId, fetchKey);
|
|
4443
2459
|
}
|
|
4444
2460
|
/**
|
|
4445
2461
|
* V2 P2P 加密发送(推测性:用缓存 bootstrap 直接发,失败刷新重试一次)。
|
|
@@ -4450,42 +2466,7 @@ export class AUNClient {
|
|
|
4450
2466
|
* @returns 服务端响应
|
|
4451
2467
|
*/
|
|
4452
2468
|
async _sendV2(to, payload, opts) {
|
|
4453
|
-
|
|
4454
|
-
throw new StateError('V2 session not initialized (not connected?)');
|
|
4455
|
-
}
|
|
4456
|
-
const toAid = String(to ?? '').trim();
|
|
4457
|
-
if (!toAid)
|
|
4458
|
-
throw new ValidationError("message.send requires 'to'");
|
|
4459
|
-
if (!isJsonObject(payload))
|
|
4460
|
-
throw new ValidationError('message.send payload must be a dict for V2 encryption');
|
|
4461
|
-
const attempt = async (useCache) => {
|
|
4462
|
-
const envelope = await this._buildV2P2PEnvelope({
|
|
4463
|
-
to: toAid,
|
|
4464
|
-
payload,
|
|
4465
|
-
messageId: opts?.messageId,
|
|
4466
|
-
timestamp: opts?.timestamp,
|
|
4467
|
-
protectedHeaders: opts?.protectedHeaders,
|
|
4468
|
-
context: opts?.context,
|
|
4469
|
-
useCache,
|
|
4470
|
-
});
|
|
4471
|
-
return this.call('message.send', {
|
|
4472
|
-
to: toAid,
|
|
4473
|
-
payload: envelope,
|
|
4474
|
-
encrypt: false,
|
|
4475
|
-
});
|
|
4476
|
-
};
|
|
4477
|
-
try {
|
|
4478
|
-
return await attempt(true);
|
|
4479
|
-
}
|
|
4480
|
-
catch (exc) {
|
|
4481
|
-
const excCode = exc?.code;
|
|
4482
|
-
if (AUNClient.V2_RETRYABLE_CODES.has(excCode)) {
|
|
4483
|
-
this._clientLog.debug(`V2 P2P speculative send rejected (code=${excCode}), refreshing bootstrap`);
|
|
4484
|
-
this._v2BootstrapCache.delete(toAid);
|
|
4485
|
-
return attempt(false);
|
|
4486
|
-
}
|
|
4487
|
-
throw exc;
|
|
4488
|
-
}
|
|
2469
|
+
return await this._v2E2EE.sendV2(to, payload, opts);
|
|
4489
2470
|
}
|
|
4490
2471
|
/**
|
|
4491
2472
|
* 拉取并解密 V2 P2P 消息。
|
|
@@ -4494,117 +2475,7 @@ export class AUNClient {
|
|
|
4494
2475
|
* @param limit 最多拉取条数
|
|
4495
2476
|
*/
|
|
4496
2477
|
async _pullV2(afterSeq = 0, limit = 50, opts) {
|
|
4497
|
-
|
|
4498
|
-
throw new StateError('V2 session not initialized (not connected?)');
|
|
4499
|
-
}
|
|
4500
|
-
const ns = this._aid ? `p2p:${this._aid}` : '';
|
|
4501
|
-
const decrypted = [];
|
|
4502
|
-
let nextAfterSeq = opts?.force ? afterSeq : (afterSeq || (ns ? this._seqTracker.getContiguousSeq(ns) : 0));
|
|
4503
|
-
let pageCount = 0;
|
|
4504
|
-
const maxPages = 100;
|
|
4505
|
-
while (pageCount < maxPages) {
|
|
4506
|
-
pageCount += 1;
|
|
4507
|
-
const result = await this._callRawV2Rpc('message.v2.pull', {
|
|
4508
|
-
after_seq: nextAfterSeq,
|
|
4509
|
-
limit,
|
|
4510
|
-
...(opts?.force ? { force: true } : {}),
|
|
4511
|
-
});
|
|
4512
|
-
const messages = (Array.isArray(result?.messages) ? result.messages : []);
|
|
4513
|
-
const seqs = messages
|
|
4514
|
-
.map((msg) => Number(msg.seq ?? 0))
|
|
4515
|
-
.filter((seq) => Number.isFinite(seq) && seq > 0);
|
|
4516
|
-
const pageContigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
|
|
4517
|
-
const pageMaxSeq = seqs.length > 0 ? Math.max(...seqs) : nextAfterSeq;
|
|
4518
|
-
if (ns && seqs.length > 0) {
|
|
4519
|
-
this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
|
|
4520
|
-
}
|
|
4521
|
-
for (const msg of messages) {
|
|
4522
|
-
const seq = Number(msg.seq ?? 0);
|
|
4523
|
-
if (!Number.isFinite(seq) || seq <= 0)
|
|
4524
|
-
continue;
|
|
4525
|
-
const version = String(msg.version ?? 'v2');
|
|
4526
|
-
if (version === 'v1') {
|
|
4527
|
-
const legacy = isJsonObject(msg.legacy_v1) ? msg.legacy_v1 : {};
|
|
4528
|
-
const legacyPayload = legacy.payload;
|
|
4529
|
-
const payloadType = isJsonObject(legacyPayload)
|
|
4530
|
-
? String(legacyPayload.type ?? '').trim()
|
|
4531
|
-
: '';
|
|
4532
|
-
if (legacyPayload !== undefined && legacyPayload !== null
|
|
4533
|
-
&& payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
|
|
4534
|
-
const v1Msg = {
|
|
4535
|
-
message_id: String(msg.message_id ?? ''),
|
|
4536
|
-
from: String(msg.from_aid ?? ''),
|
|
4537
|
-
to: String(legacy.to ?? this._aid ?? ''),
|
|
4538
|
-
seq: msg.seq,
|
|
4539
|
-
type: String(msg.type ?? ''),
|
|
4540
|
-
timestamp: msg.t_server,
|
|
4541
|
-
payload: legacyPayload,
|
|
4542
|
-
encrypted: false,
|
|
4543
|
-
};
|
|
4544
|
-
if (ns)
|
|
4545
|
-
await this._publishPulledMessage('message.received', ns, seq, v1Msg);
|
|
4546
|
-
else
|
|
4547
|
-
await this._publishAppEvent('message.received', v1Msg);
|
|
4548
|
-
decrypted.push(v1Msg);
|
|
4549
|
-
}
|
|
4550
|
-
else {
|
|
4551
|
-
this._clientLog.debug(`message.v2.pull skipping V1 envelope seq=${seq} payload_type=${payloadType || '<none>'} (V1 E2EE removed)`);
|
|
4552
|
-
}
|
|
4553
|
-
continue;
|
|
4554
|
-
}
|
|
4555
|
-
if (version !== 'v2') {
|
|
4556
|
-
this._clientLog.debug(`message.v2.pull skipping non-V2 row seq=${seq} version=${String(msg.version ?? '')}`);
|
|
4557
|
-
continue;
|
|
4558
|
-
}
|
|
4559
|
-
// 跟踪每个旧 SPK 引用的最大 seq(用于消费后销毁)
|
|
4560
|
-
const msgSpkId = String(msg.spk_id ?? '');
|
|
4561
|
-
if (msgSpkId && this._v2Session && !this._v2Session.isCurrentSPK(msgSpkId)) {
|
|
4562
|
-
this._v2Session.trackOldSPKMaxSeq(msgSpkId, seq);
|
|
4563
|
-
}
|
|
4564
|
-
const plaintext = await this._decryptV2Message(msg);
|
|
4565
|
-
if (plaintext === null)
|
|
4566
|
-
continue;
|
|
4567
|
-
if (ns) {
|
|
4568
|
-
await this._publishPulledMessage('message.received', ns, seq, plaintext);
|
|
4569
|
-
decrypted.push(plaintext);
|
|
4570
|
-
}
|
|
4571
|
-
else {
|
|
4572
|
-
await this._publishAppEvent('message.received', plaintext);
|
|
4573
|
-
decrypted.push(plaintext);
|
|
4574
|
-
}
|
|
4575
|
-
}
|
|
4576
|
-
const hasServerAckSeq = Object.prototype.hasOwnProperty.call(result, 'server_ack_seq');
|
|
4577
|
-
const serverAckSeq = Number(result.server_ack_seq ?? 0);
|
|
4578
|
-
if (ns && Number.isFinite(serverAckSeq) && serverAckSeq > 0) {
|
|
4579
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
4580
|
-
if (contig < serverAckSeq) {
|
|
4581
|
-
this._clientLog.info(`message.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> server_ack_seq=${serverAckSeq}`);
|
|
4582
|
-
this._seqTracker.forceContiguousSeq(ns, serverAckSeq);
|
|
4583
|
-
}
|
|
4584
|
-
}
|
|
4585
|
-
if (ns) {
|
|
4586
|
-
const ackSeq = this._seqTracker.getContiguousSeq(ns);
|
|
4587
|
-
const contigAdvanced = ackSeq !== pageContigBefore;
|
|
4588
|
-
if (contigAdvanced) {
|
|
4589
|
-
await this._drainOrderedMessages(ns);
|
|
4590
|
-
this._saveSeqTrackerState();
|
|
4591
|
-
}
|
|
4592
|
-
const ackNeeded = messages.length > 0
|
|
4593
|
-
&& ackSeq > 0
|
|
4594
|
-
&& (contigAdvanced || (hasServerAckSeq && ackSeq > serverAckSeq));
|
|
4595
|
-
if (ackNeeded) {
|
|
4596
|
-
this._safeAsync(this._ackV2(ackSeq).then(() => undefined));
|
|
4597
|
-
}
|
|
4598
|
-
}
|
|
4599
|
-
const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
|
|
4600
|
-
if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
|
|
4601
|
-
break;
|
|
4602
|
-
nextAfterSeq = nextAfter;
|
|
4603
|
-
}
|
|
4604
|
-
if (pageCount >= maxPages) {
|
|
4605
|
-
this._clientLog.warn(`message.v2.pull reached max_pages=${maxPages} after_seq=${nextAfterSeq}`);
|
|
4606
|
-
}
|
|
4607
|
-
return decrypted;
|
|
2478
|
+
return await this._v2E2EE.pullV2(afterSeq, limit, opts);
|
|
4608
2479
|
}
|
|
4609
2480
|
/**
|
|
4610
2481
|
* 确认 V2 消息已消费 + 自检销毁旧 SPK(PFS)。
|
|
@@ -4612,47 +2483,7 @@ export class AUNClient {
|
|
|
4612
2483
|
* @param upToSeq 确认到此 seq;省略则用当前 contiguous
|
|
4613
2484
|
*/
|
|
4614
2485
|
async _ackV2(upToSeq) {
|
|
4615
|
-
|
|
4616
|
-
let seq = upToSeq ?? (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
|
|
4617
|
-
if (seq <= 0)
|
|
4618
|
-
return { acked: 0 };
|
|
4619
|
-
// ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
|
|
4620
|
-
if (ns) {
|
|
4621
|
-
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
4622
|
-
if (maxSeen > 0 && seq > maxSeen) {
|
|
4623
|
-
this._clientLog.warn(`ackV2 clamp: up_to_seq=${seq} > max_seen=${maxSeen}, clamp`);
|
|
4624
|
-
seq = maxSeen;
|
|
4625
|
-
}
|
|
4626
|
-
}
|
|
4627
|
-
const raw = await this._callRawV2Rpc('message.v2.ack', { up_to_seq: seq });
|
|
4628
|
-
const result = isJsonObject(raw)
|
|
4629
|
-
? { ...raw }
|
|
4630
|
-
: { result: raw };
|
|
4631
|
-
let actualAckSeq = seq;
|
|
4632
|
-
if ('effective_ack_seq' in result)
|
|
4633
|
-
actualAckSeq = Number(result.effective_ack_seq ?? 0);
|
|
4634
|
-
else if ('ack_seq' in result)
|
|
4635
|
-
actualAckSeq = Number(result.ack_seq ?? 0);
|
|
4636
|
-
else if ('cursor' in result)
|
|
4637
|
-
actualAckSeq = Number(result.cursor ?? 0);
|
|
4638
|
-
if (!Number.isFinite(actualAckSeq))
|
|
4639
|
-
actualAckSeq = seq;
|
|
4640
|
-
result.ack_seq = actualAckSeq;
|
|
4641
|
-
result.success = true;
|
|
4642
|
-
if (Number(result.acked ?? 0) === 0)
|
|
4643
|
-
result.acked = actualAckSeq;
|
|
4644
|
-
if (this._v2Session) {
|
|
4645
|
-
try {
|
|
4646
|
-
const destroyed = await this._v2Session.maybeDestroyOldSPKs(actualAckSeq);
|
|
4647
|
-
if (destroyed.length > 0) {
|
|
4648
|
-
this._clientLog.info(`V2 destroyed old SPKs after ack: ${destroyed.slice(0, 3)} (PFS)`);
|
|
4649
|
-
}
|
|
4650
|
-
}
|
|
4651
|
-
catch (exc) {
|
|
4652
|
-
this._clientLog.debug(`V2 SPK destroy failed (non-fatal): ${String(exc)}`);
|
|
4653
|
-
}
|
|
4654
|
-
}
|
|
4655
|
-
return result;
|
|
2486
|
+
return await this._v2E2EE.ackV2(upToSeq);
|
|
4656
2487
|
}
|
|
4657
2488
|
/** 解密单条 V2 消息(与 Python `_decrypt_v2_message` 对齐)。缺 sender IK 时先入 pending,后台补齐后重试。 */
|
|
4658
2489
|
async _decryptV2Message(msg, allowPending = true) {
|
|
@@ -4744,7 +2575,8 @@ export class AUNClient {
|
|
|
4744
2575
|
}
|
|
4745
2576
|
const fromAid = String(msg.from_aid ?? '');
|
|
4746
2577
|
const senderDeviceId = String(aad.from_device ?? '');
|
|
4747
|
-
const
|
|
2578
|
+
const senderCertFingerprint = String(envelope.sender_cert_fingerprint ?? '').trim().toLowerCase();
|
|
2579
|
+
const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId, senderCertFingerprint);
|
|
4748
2580
|
if (!senderPubDer) {
|
|
4749
2581
|
this._clientLog.warn(`V2 decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
|
|
4750
2582
|
if (allowPending) {
|
|
@@ -4764,7 +2596,6 @@ export class AUNClient {
|
|
|
4764
2596
|
_decrypt_stage: 'sender_ik',
|
|
4765
2597
|
_envelope_type: String(envelope.type ?? ''),
|
|
4766
2598
|
_suite: String(envelope.suite ?? ''),
|
|
4767
|
-
_sender_device_id: String(aad.from_device ?? ''),
|
|
4768
2599
|
};
|
|
4769
2600
|
attachV2EnvelopeMetadata(event, e2eeMeta);
|
|
4770
2601
|
await this._dispatcher.publish(undecryptableEvent, event);
|
|
@@ -4791,7 +2622,6 @@ export class AUNClient {
|
|
|
4791
2622
|
_decrypt_stage: 'decrypt',
|
|
4792
2623
|
_envelope_type: String(envelope.type ?? ''),
|
|
4793
2624
|
_suite: String(envelope.suite ?? ''),
|
|
4794
|
-
_sender_device_id: String(aad.from_device ?? ''),
|
|
4795
2625
|
};
|
|
4796
2626
|
attachV2EnvelopeMetadata(event, e2eeMeta);
|
|
4797
2627
|
await this._dispatcher.publish(undecryptableEvent, event);
|
|
@@ -4803,16 +2633,10 @@ export class AUNClient {
|
|
|
4803
2633
|
return null;
|
|
4804
2634
|
// 消费触发 SPK 轮换(fire-and-forget,不阻塞消息处理)
|
|
4805
2635
|
if (groupIdForKeys && recipientKeySource === 'group_device_prekey' && session.isLastUploadedGroupSPK(groupIdForKeys, spkId)) {
|
|
4806
|
-
|
|
4807
|
-
session.rotateGroupSPK(groupIdForKeys, callFn).catch(exc => {
|
|
4808
|
-
this._clientLog.debug(`V2 group SPK rotation failed (non-fatal): group=${groupIdForKeys} err=${exc}`);
|
|
4809
|
-
});
|
|
2636
|
+
this._v2E2EE.scheduleGroupSpkRotation(groupIdForKeys, { reason: 'group_spk_consumed' });
|
|
4810
2637
|
}
|
|
4811
2638
|
else if (groupIdForKeys && recipientKeySource === 'peer_device_prekey') {
|
|
4812
|
-
|
|
4813
|
-
session.ensureGroupRegistered(groupIdForKeys, callFn).catch(exc => {
|
|
4814
|
-
this._clientLog.debug(`V2 group SPK registration after peer fallback failed (non-fatal): group=${groupIdForKeys} err=${exc}`);
|
|
4815
|
-
});
|
|
2639
|
+
this._v2E2EE.scheduleGroupSpkRegistrationAfterPeerFallback(groupIdForKeys);
|
|
4816
2640
|
}
|
|
4817
2641
|
else if (!groupIdForKeys && session.isLastUploadedSPK(spkId)) {
|
|
4818
2642
|
const callFn = async (method, params) => this.call(method, params);
|
|
@@ -4837,6 +2661,7 @@ export class AUNClient {
|
|
|
4837
2661
|
result.device_id = msg.device_id;
|
|
4838
2662
|
if (msg.slot_id !== undefined)
|
|
4839
2663
|
result.slot_id = msg.slot_id;
|
|
2664
|
+
attachGatewayProximity(result, msg);
|
|
4840
2665
|
attachV2EnvelopeMetadata(result, e2ee);
|
|
4841
2666
|
return result;
|
|
4842
2667
|
}
|
|
@@ -4849,65 +2674,10 @@ export class AUNClient {
|
|
|
4849
2674
|
* @returns 服务端响应
|
|
4850
2675
|
*/
|
|
4851
2676
|
async _sendGroupV2(groupId, payload, opts) {
|
|
4852
|
-
|
|
4853
|
-
throw new StateError('V2 session not initialized (not connected?)');
|
|
4854
|
-
}
|
|
4855
|
-
const gid = normalizeGroupId(groupId) || String(groupId ?? '').trim();
|
|
4856
|
-
if (!gid)
|
|
4857
|
-
throw new ValidationError("group.send requires 'group_id'");
|
|
4858
|
-
if (!isJsonObject(payload))
|
|
4859
|
-
throw new ValidationError('group.send payload must be a dict for V2 encryption');
|
|
4860
|
-
const attempt = async (useCache) => {
|
|
4861
|
-
const envelope = await this._buildV2GroupEnvelope({
|
|
4862
|
-
groupId: gid,
|
|
4863
|
-
payload,
|
|
4864
|
-
messageId: opts?.messageId,
|
|
4865
|
-
timestamp: opts?.timestamp,
|
|
4866
|
-
protectedHeaders: opts?.protectedHeaders,
|
|
4867
|
-
context: opts?.context,
|
|
4868
|
-
useCache,
|
|
4869
|
-
});
|
|
4870
|
-
return this.call('group.v2.send', {
|
|
4871
|
-
group_id: gid,
|
|
4872
|
-
envelope: envelope,
|
|
4873
|
-
});
|
|
4874
|
-
};
|
|
4875
|
-
try {
|
|
4876
|
-
const result = await attempt(true);
|
|
4877
|
-
// 发送成功后记录自己发的消息 seq,保证 SeqTracker 连续 + push 时去重
|
|
4878
|
-
if (isJsonObject(result)) {
|
|
4879
|
-
const seq = Number(result.seq ?? 0);
|
|
4880
|
-
if (seq > 0) {
|
|
4881
|
-
const ns = `group:${gid}`;
|
|
4882
|
-
this._seqTracker.onMessageSeq(ns, seq);
|
|
4883
|
-
this._markPublishedSeq(ns, seq);
|
|
4884
|
-
this._saveSeqTrackerState();
|
|
4885
|
-
}
|
|
4886
|
-
}
|
|
4887
|
-
return result;
|
|
4888
|
-
}
|
|
4889
|
-
catch (exc) {
|
|
4890
|
-
const excCode = exc?.code;
|
|
4891
|
-
if (AUNClient.V2_RETRYABLE_CODES.has(excCode)) {
|
|
4892
|
-
this._clientLog.debug(`V2 group speculative send rejected (code=${excCode}), refreshing bootstrap`);
|
|
4893
|
-
this._v2BootstrapCache.delete(`group:${gid}`);
|
|
4894
|
-
const result = await attempt(false);
|
|
4895
|
-
if (isJsonObject(result)) {
|
|
4896
|
-
const seq = Number(result.seq ?? 0);
|
|
4897
|
-
if (seq > 0) {
|
|
4898
|
-
const ns = `group:${gid}`;
|
|
4899
|
-
this._seqTracker.onMessageSeq(ns, seq);
|
|
4900
|
-
this._markPublishedSeq(ns, seq);
|
|
4901
|
-
this._saveSeqTrackerState();
|
|
4902
|
-
}
|
|
4903
|
-
}
|
|
4904
|
-
return result;
|
|
4905
|
-
}
|
|
4906
|
-
throw exc;
|
|
4907
|
-
}
|
|
2677
|
+
return await this._v2E2EE.sendGroupV2(groupId, payload, opts);
|
|
4908
2678
|
}
|
|
4909
2679
|
async _pullGroupV2Internal(params) {
|
|
4910
|
-
await this.
|
|
2680
|
+
return await this._v2E2EE.pullGroupV2Internal(params);
|
|
4911
2681
|
}
|
|
4912
2682
|
/**
|
|
4913
2683
|
* 拉取并解密 V2 Group 消息。
|
|
@@ -4917,146 +2687,7 @@ export class AUNClient {
|
|
|
4917
2687
|
* @param limit 最多拉取条数
|
|
4918
2688
|
*/
|
|
4919
2689
|
async _pullGroupV2(groupId, afterSeq = 0, limit = 50, opts) {
|
|
4920
|
-
|
|
4921
|
-
throw new StateError('V2 session not initialized (not connected?)');
|
|
4922
|
-
}
|
|
4923
|
-
const gid = normalizeGroupId(groupId) || String(groupId ?? '').trim();
|
|
4924
|
-
if (!gid)
|
|
4925
|
-
throw new ValidationError('group.pull requires group_id');
|
|
4926
|
-
const ns = `group:${gid}`;
|
|
4927
|
-
const decrypted = [];
|
|
4928
|
-
const cursorParams = opts?.cursorParams ?? {};
|
|
4929
|
-
const ownsCursor = opts?.ownsCursor !== false;
|
|
4930
|
-
let nextAfterSeq = opts?.explicitAfterSeq ? afterSeq : (afterSeq || this._seqTracker.getContiguousSeq(ns));
|
|
4931
|
-
let pageCount = 0;
|
|
4932
|
-
const maxPages = 100;
|
|
4933
|
-
while (pageCount < maxPages) {
|
|
4934
|
-
pageCount += 1;
|
|
4935
|
-
const result = await this._callRawV2Rpc('group.v2.pull', {
|
|
4936
|
-
group_id: gid,
|
|
4937
|
-
after_seq: nextAfterSeq,
|
|
4938
|
-
limit,
|
|
4939
|
-
...cursorParams,
|
|
4940
|
-
});
|
|
4941
|
-
const messages = (Array.isArray(result?.messages) ? result.messages : []);
|
|
4942
|
-
const seqs = messages
|
|
4943
|
-
.map((msg) => Number(msg.seq ?? 0))
|
|
4944
|
-
.filter((seq) => Number.isFinite(seq) && seq > 0);
|
|
4945
|
-
const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
4946
|
-
const pageMaxSeq = seqs.length > 0 ? Math.max(...seqs) : nextAfterSeq;
|
|
4947
|
-
if (seqs.length > 0) {
|
|
4948
|
-
this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
|
|
4949
|
-
}
|
|
4950
|
-
for (const msg of messages) {
|
|
4951
|
-
const seq = Number(msg.seq ?? 0);
|
|
4952
|
-
if (!Number.isFinite(seq) || seq <= 0)
|
|
4953
|
-
continue;
|
|
4954
|
-
const version = String(msg.version ?? 'v2');
|
|
4955
|
-
if (version === 'v1') {
|
|
4956
|
-
const payload = msg.payload;
|
|
4957
|
-
const payloadObj = isJsonObject(payload) ? payload : null;
|
|
4958
|
-
if (payloadObj) {
|
|
4959
|
-
const payloadType = String(payloadObj.type ?? '').trim();
|
|
4960
|
-
if (payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
|
|
4961
|
-
const v1Msg = {
|
|
4962
|
-
message_id: String(msg.message_id ?? ''),
|
|
4963
|
-
from: String(msg.from_aid ?? ''),
|
|
4964
|
-
group_id: gid,
|
|
4965
|
-
seq: msg.seq,
|
|
4966
|
-
type: String(msg.type ?? ''),
|
|
4967
|
-
timestamp: msg.t_server,
|
|
4968
|
-
payload,
|
|
4969
|
-
encrypted: false,
|
|
4970
|
-
};
|
|
4971
|
-
await this._publishPulledMessage('group.message_created', ns, seq, v1Msg);
|
|
4972
|
-
decrypted.push(v1Msg);
|
|
4973
|
-
continue;
|
|
4974
|
-
}
|
|
4975
|
-
}
|
|
4976
|
-
else if (payload !== undefined && payload !== null) {
|
|
4977
|
-
const v1Msg = {
|
|
4978
|
-
message_id: String(msg.message_id ?? ''),
|
|
4979
|
-
from: String(msg.from_aid ?? ''),
|
|
4980
|
-
group_id: gid,
|
|
4981
|
-
seq: msg.seq,
|
|
4982
|
-
type: String(msg.type ?? ''),
|
|
4983
|
-
timestamp: msg.t_server,
|
|
4984
|
-
payload,
|
|
4985
|
-
encrypted: false,
|
|
4986
|
-
};
|
|
4987
|
-
await this._publishPulledMessage('group.message_created', ns, seq, v1Msg);
|
|
4988
|
-
decrypted.push(v1Msg);
|
|
4989
|
-
continue;
|
|
4990
|
-
}
|
|
4991
|
-
this._clientLog.debug(`group.v2.pull skipping V1 envelope group=${gid} seq=${seq} payload_type=${payloadObj ? String(payloadObj.type ?? '') : '<none>'} (V1 E2EE removed)`);
|
|
4992
|
-
continue;
|
|
4993
|
-
}
|
|
4994
|
-
if (version !== 'v2') {
|
|
4995
|
-
this._clientLog.debug(`group.v2.pull skipping non-V2 row group=${gid} seq=${seq} version=${String(msg.version ?? '')}`);
|
|
4996
|
-
continue;
|
|
4997
|
-
}
|
|
4998
|
-
const plaintext = await this._decryptV2Message(msg);
|
|
4999
|
-
if (plaintext === null)
|
|
5000
|
-
continue;
|
|
5001
|
-
plaintext.group_id = gid;
|
|
5002
|
-
await this._publishPulledMessage('group.message_created', ns, seq, plaintext);
|
|
5003
|
-
decrypted.push(plaintext);
|
|
5004
|
-
}
|
|
5005
|
-
const cursor = isJsonObject(result.cursor) ? result.cursor : null;
|
|
5006
|
-
const hasServerCursor = cursor !== null && Object.prototype.hasOwnProperty.call(cursor, 'current_seq');
|
|
5007
|
-
const serverAckSeq = Number(cursor?.current_seq ?? 0);
|
|
5008
|
-
if (Number.isFinite(serverAckSeq) && serverAckSeq > 0) {
|
|
5009
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
5010
|
-
if (contig < serverAckSeq) {
|
|
5011
|
-
this._clientLog.info(`group.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> cursor.current_seq=${serverAckSeq}`);
|
|
5012
|
-
this._seqTracker.forceContiguousSeq(ns, serverAckSeq);
|
|
5013
|
-
}
|
|
5014
|
-
}
|
|
5015
|
-
const ackSeq = this._seqTracker.getContiguousSeq(ns);
|
|
5016
|
-
const contigAdvanced = ackSeq !== pageContigBefore;
|
|
5017
|
-
if (contigAdvanced) {
|
|
5018
|
-
await this._drainOrderedMessages(ns);
|
|
5019
|
-
this._saveSeqTrackerState();
|
|
5020
|
-
}
|
|
5021
|
-
const ackNeeded = messages.length > 0
|
|
5022
|
-
&& ackSeq > 0
|
|
5023
|
-
&& ownsCursor
|
|
5024
|
-
&& (contigAdvanced || (hasServerCursor && ackSeq > serverAckSeq));
|
|
5025
|
-
if (ackNeeded) {
|
|
5026
|
-
this._safeAsync(this._ackGroupV2(gid, ackSeq).then(() => undefined));
|
|
5027
|
-
}
|
|
5028
|
-
const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
|
|
5029
|
-
if (!ownsCursor)
|
|
5030
|
-
break;
|
|
5031
|
-
if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
|
|
5032
|
-
break;
|
|
5033
|
-
nextAfterSeq = nextAfter;
|
|
5034
|
-
}
|
|
5035
|
-
if (pageCount >= maxPages) {
|
|
5036
|
-
this._clientLog.warn(`group.v2.pull reached max_pages=${maxPages} group=${gid} after_seq=${nextAfterSeq}`);
|
|
5037
|
-
}
|
|
5038
|
-
return decrypted;
|
|
5039
|
-
}
|
|
5040
|
-
_groupCursorParams(params) {
|
|
5041
|
-
const cursorParams = {};
|
|
5042
|
-
for (const key of ['device_id', 'slot_id', 'device_name', 'device_type']) {
|
|
5043
|
-
const value = params[key];
|
|
5044
|
-
if (value !== undefined && value !== null)
|
|
5045
|
-
cursorParams[key] = value;
|
|
5046
|
-
}
|
|
5047
|
-
return cursorParams;
|
|
5048
|
-
}
|
|
5049
|
-
_explicitGroupCursorParams(params) {
|
|
5050
|
-
const value = params._group_cursor_params;
|
|
5051
|
-
if (!isJsonObject(value))
|
|
5052
|
-
return {};
|
|
5053
|
-
return { ...value };
|
|
5054
|
-
}
|
|
5055
|
-
_groupCursorTargetsCurrentInstance(params) {
|
|
5056
|
-
const deviceId = String(params.device_id ?? '').trim();
|
|
5057
|
-
const slotId = String(params.slot_id ?? '').trim();
|
|
5058
|
-
return (!deviceId || deviceId === (this._deviceId ?? ''))
|
|
5059
|
-
&& (!slotId || slotId === (this._slotId ?? ''));
|
|
2690
|
+
return await this._v2E2EE.pullGroupV2(groupId, afterSeq, limit, opts);
|
|
5060
2691
|
}
|
|
5061
2692
|
async _rawGroupAckMessages(params) {
|
|
5062
2693
|
const p = { ...params };
|
|
@@ -5069,132 +2700,15 @@ export class AUNClient {
|
|
|
5069
2700
|
* @param upToSeq 确认到此 seq;省略则用当前 contiguous
|
|
5070
2701
|
*/
|
|
5071
2702
|
async _ackGroupV2(groupId, upToSeq) {
|
|
5072
|
-
|
|
5073
|
-
if (!gid)
|
|
5074
|
-
throw new ValidationError('group.ack_messages requires group_id');
|
|
5075
|
-
const ns = `group:${gid}`;
|
|
5076
|
-
let seq = upToSeq ?? this._seqTracker.getContiguousSeq(ns);
|
|
5077
|
-
if (seq <= 0)
|
|
5078
|
-
return { acked: 0 };
|
|
5079
|
-
// ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
|
|
5080
|
-
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
5081
|
-
if (maxSeen > 0 && seq > maxSeen) {
|
|
5082
|
-
this._clientLog.warn(`ackGroupV2 clamp: group=${gid} up_to_seq=${seq} > max_seen=${maxSeen}, clamp`);
|
|
5083
|
-
seq = maxSeen;
|
|
5084
|
-
}
|
|
5085
|
-
return this._callRawV2Rpc('group.v2.ack', { group_id: gid, up_to_seq: seq });
|
|
2703
|
+
return await this._v2E2EE.ackGroupV2(groupId, upToSeq);
|
|
5086
2704
|
}
|
|
5087
|
-
// ── V2 thought(per-device wrap,服务端透传,不持久化)──────────
|
|
5088
2705
|
/**
|
|
5089
2706
|
* V2 P2P 多设备 wrap envelope 构造(不发送)。
|
|
5090
2707
|
* thought.put 复用此函数构造与 message.send 相同的 V2 envelope。
|
|
5091
2708
|
* 与 Python `_build_v2_p2p_envelope` 对齐。
|
|
5092
2709
|
*/
|
|
5093
2710
|
async _buildV2P2PEnvelope(opts) {
|
|
5094
|
-
|
|
5095
|
-
throw new StateError('V2 session not initialized');
|
|
5096
|
-
}
|
|
5097
|
-
const session = this._v2Session;
|
|
5098
|
-
const to = opts.to;
|
|
5099
|
-
const useCache = opts.useCache !== false;
|
|
5100
|
-
let peerDevices = [];
|
|
5101
|
-
let auditRaw = [];
|
|
5102
|
-
let wrapPolicy;
|
|
5103
|
-
const cached = useCache ? this._v2BootstrapCache.get(to) : undefined;
|
|
5104
|
-
if (cached && (Date.now() - cached.cachedAt) < AUNClient.V2_BOOTSTRAP_TTL_MS) {
|
|
5105
|
-
peerDevices = cached.devices;
|
|
5106
|
-
auditRaw = cached.auditRecipients;
|
|
5107
|
-
wrapPolicy = cached.wrapPolicy;
|
|
5108
|
-
}
|
|
5109
|
-
else {
|
|
5110
|
-
const bs = await this.call('message.v2.bootstrap', {
|
|
5111
|
-
peer_aid: to,
|
|
5112
|
-
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
5113
|
-
});
|
|
5114
|
-
peerDevices = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
|
|
5115
|
-
auditRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
|
|
5116
|
-
wrapPolicy = normalizeV2WrapPolicy(bs?.e2ee_wrap_policy);
|
|
5117
|
-
if (peerDevices.length > 0) {
|
|
5118
|
-
this._v2BootstrapCache.set(to, {
|
|
5119
|
-
devices: peerDevices,
|
|
5120
|
-
auditRecipients: auditRaw,
|
|
5121
|
-
cachedAt: Date.now(),
|
|
5122
|
-
wrapPolicy,
|
|
5123
|
-
});
|
|
5124
|
-
}
|
|
5125
|
-
}
|
|
5126
|
-
if (peerDevices.length === 0) {
|
|
5127
|
-
throw new E2EEError(`V2 bootstrap: no devices found for ${to}`);
|
|
5128
|
-
}
|
|
5129
|
-
const targets = [];
|
|
5130
|
-
for (const dev of peerDevices) {
|
|
5131
|
-
const devId = getV2DeviceId(dev);
|
|
5132
|
-
const target = await this._v2BuildTargetFromDevice({
|
|
5133
|
-
dev,
|
|
5134
|
-
aid: to,
|
|
5135
|
-
deviceId: devId.value,
|
|
5136
|
-
role: 'peer',
|
|
5137
|
-
defaultKeySource: 'peer_device_prekey',
|
|
5138
|
-
});
|
|
5139
|
-
if (target)
|
|
5140
|
-
targets.push(target);
|
|
5141
|
-
}
|
|
5142
|
-
for (const dev of auditRaw) {
|
|
5143
|
-
const target = await this._v2BuildTargetFromDevice({
|
|
5144
|
-
dev,
|
|
5145
|
-
aid: String(dev.aid ?? ''),
|
|
5146
|
-
deviceId: String(dev.device_id ?? ''),
|
|
5147
|
-
role: 'audit',
|
|
5148
|
-
defaultKeySource: 'peer_device_prekey',
|
|
5149
|
-
});
|
|
5150
|
-
if (target)
|
|
5151
|
-
targets.push(target);
|
|
5152
|
-
}
|
|
5153
|
-
// self-sync:自己其它设备
|
|
5154
|
-
if (this._aid && this._aid !== to) {
|
|
5155
|
-
try {
|
|
5156
|
-
const selfCached = this._v2BootstrapCache.get(this._aid);
|
|
5157
|
-
let selfDevices = [];
|
|
5158
|
-
if (selfCached && (Date.now() - selfCached.cachedAt) < AUNClient.V2_BOOTSTRAP_TTL_MS) {
|
|
5159
|
-
selfDevices = selfCached.devices;
|
|
5160
|
-
}
|
|
5161
|
-
else {
|
|
5162
|
-
const selfBs = await this.call('message.v2.bootstrap', {
|
|
5163
|
-
peer_aid: this._aid,
|
|
5164
|
-
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
5165
|
-
});
|
|
5166
|
-
selfDevices = (Array.isArray(selfBs?.peer_devices) ? selfBs.peer_devices : []);
|
|
5167
|
-
if (selfDevices.length > 0) {
|
|
5168
|
-
this._v2BootstrapCache.set(this._aid, {
|
|
5169
|
-
devices: selfDevices,
|
|
5170
|
-
auditRecipients: [],
|
|
5171
|
-
cachedAt: Date.now(),
|
|
5172
|
-
});
|
|
5173
|
-
}
|
|
5174
|
-
}
|
|
5175
|
-
for (const dev of selfDevices) {
|
|
5176
|
-
const devId = getV2DeviceId(dev);
|
|
5177
|
-
if (!devId.present || devId.value === this._deviceId)
|
|
5178
|
-
continue;
|
|
5179
|
-
const target = await this._v2BuildTargetFromDevice({
|
|
5180
|
-
dev,
|
|
5181
|
-
aid: this._aid,
|
|
5182
|
-
deviceId: devId.value,
|
|
5183
|
-
role: 'self_sync',
|
|
5184
|
-
defaultKeySource: 'peer_device_prekey',
|
|
5185
|
-
});
|
|
5186
|
-
if (target)
|
|
5187
|
-
targets.push(target);
|
|
5188
|
-
}
|
|
5189
|
-
}
|
|
5190
|
-
catch (exc) {
|
|
5191
|
-
this._clientLog.debug(`V2 thought self-sync bootstrap failed (non-fatal): ${String(exc)}`);
|
|
5192
|
-
}
|
|
5193
|
-
}
|
|
5194
|
-
const sender = await session.getSenderIdentity();
|
|
5195
|
-
const sendTargets = applyV2WrapPolicyToTargets(targets, wrapPolicy);
|
|
5196
|
-
const envelope = await encryptP2PMessage(sender, { targets: sendTargets, auditRecipients: [] }, opts.payload, { messageId: opts.messageId, timestamp: opts.timestamp, protectedHeaders: opts.protectedHeaders, context: opts.context });
|
|
5197
|
-
return envelope;
|
|
2711
|
+
return await this._v2E2EE.buildV2P2PEnvelope(opts);
|
|
5198
2712
|
}
|
|
5199
2713
|
/**
|
|
5200
2714
|
* V2 P2P thought.put:使用 V2 多设备 wrap envelope。
|
|
@@ -5202,215 +2716,21 @@ export class AUNClient {
|
|
|
5202
2716
|
* 与 Python `_put_message_thought_encrypted_v2` 对齐。
|
|
5203
2717
|
*/
|
|
5204
2718
|
async _putMessageThoughtEncryptedV2(params) {
|
|
5205
|
-
|
|
5206
|
-
this._validateMessageRecipient(toAid);
|
|
5207
|
-
const payload = isJsonObject(params.payload) ? params.payload : null;
|
|
5208
|
-
if (!toAid) {
|
|
5209
|
-
throw new ValidationError('message.thought.put requires to');
|
|
5210
|
-
}
|
|
5211
|
-
if (payload === null) {
|
|
5212
|
-
throw new ValidationError('message.thought.put payload must be an object when encrypt=true');
|
|
5213
|
-
}
|
|
5214
|
-
const thoughtId = String(params.thought_id ?? '') || `mt-${_uuidV4()}`;
|
|
5215
|
-
const timestamp = Number(params.timestamp ?? Date.now());
|
|
5216
|
-
const attempt = async (useCache) => {
|
|
5217
|
-
const envelopeContext = (params.context && typeof params.context === 'object' && !Array.isArray(params.context))
|
|
5218
|
-
? params.context : undefined;
|
|
5219
|
-
const envelope = await this._buildV2P2PEnvelope({
|
|
5220
|
-
to: toAid,
|
|
5221
|
-
payload,
|
|
5222
|
-
messageId: thoughtId,
|
|
5223
|
-
timestamp,
|
|
5224
|
-
useCache,
|
|
5225
|
-
context: envelopeContext,
|
|
5226
|
-
});
|
|
5227
|
-
const sendParams = {
|
|
5228
|
-
to: toAid,
|
|
5229
|
-
payload: envelope,
|
|
5230
|
-
encrypted: true,
|
|
5231
|
-
thought_id: thoughtId,
|
|
5232
|
-
timestamp,
|
|
5233
|
-
};
|
|
5234
|
-
if ('context' in params)
|
|
5235
|
-
sendParams.context = params.context;
|
|
5236
|
-
await this._signClientOperation('message.thought.put', sendParams);
|
|
5237
|
-
return this._transport.call('message.thought.put', sendParams);
|
|
5238
|
-
};
|
|
5239
|
-
try {
|
|
5240
|
-
return await attempt(true);
|
|
5241
|
-
}
|
|
5242
|
-
catch (exc) {
|
|
5243
|
-
const excCode = exc?.code;
|
|
5244
|
-
if (AUNClient.V2_RETRYABLE_CODES.has(excCode)) {
|
|
5245
|
-
this._clientLog.debug(`V2 P2P thought put speculative rejected (code=${excCode}), refreshing bootstrap`);
|
|
5246
|
-
this._v2BootstrapCache.delete(toAid);
|
|
5247
|
-
return attempt(false);
|
|
5248
|
-
}
|
|
5249
|
-
throw exc;
|
|
5250
|
-
}
|
|
2719
|
+
return await this._v2E2EE.putMessageThoughtEncryptedV2(params);
|
|
5251
2720
|
}
|
|
5252
2721
|
/**
|
|
5253
2722
|
* V2 Group 多设备 wrap envelope 构造(不发送)。
|
|
5254
2723
|
* 与 Python `_build_v2_group_envelope` 对齐。
|
|
5255
2724
|
*/
|
|
5256
2725
|
async _buildV2GroupEnvelope(opts) {
|
|
5257
|
-
|
|
5258
|
-
throw new StateError('V2 session not initialized');
|
|
5259
|
-
}
|
|
5260
|
-
const session = this._v2Session;
|
|
5261
|
-
const groupId = normalizeGroupId(opts.groupId) || String(opts.groupId ?? '').trim();
|
|
5262
|
-
if (!groupId)
|
|
5263
|
-
throw new ValidationError("group.send requires 'group_id'");
|
|
5264
|
-
const useCache = opts.useCache !== false;
|
|
5265
|
-
const cacheKey = `group:${groupId}`;
|
|
5266
|
-
let allDevices = [];
|
|
5267
|
-
let epoch = 0;
|
|
5268
|
-
let stateCommitment = { state_version: 0, state_hash: '', state_chain: '' };
|
|
5269
|
-
let auditRecipientsRaw = [];
|
|
5270
|
-
let wrapPolicy;
|
|
5271
|
-
const cached = useCache ? this._v2BootstrapCache.get(cacheKey) : undefined;
|
|
5272
|
-
if (cached && (Date.now() - cached.cachedAt) < AUNClient.V2_BOOTSTRAP_TTL_MS) {
|
|
5273
|
-
allDevices = cached.devices;
|
|
5274
|
-
epoch = cached.epoch ?? 0;
|
|
5275
|
-
stateCommitment = cached.stateCommitment ?? { state_version: 0, state_hash: '', state_chain: '' };
|
|
5276
|
-
auditRecipientsRaw = cached.auditRecipients;
|
|
5277
|
-
wrapPolicy = cached.wrapPolicy;
|
|
5278
|
-
}
|
|
5279
|
-
else {
|
|
5280
|
-
const bs = await this.call('group.v2.bootstrap', {
|
|
5281
|
-
group_id: groupId,
|
|
5282
|
-
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
5283
|
-
});
|
|
5284
|
-
allDevices = (Array.isArray(bs?.devices) ? bs.devices : []);
|
|
5285
|
-
epoch = Number(bs?.epoch ?? 0);
|
|
5286
|
-
auditRecipientsRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
|
|
5287
|
-
wrapPolicy = normalizeV2WrapPolicy(bs?.e2ee_wrap_policy);
|
|
5288
|
-
await this._v2CheckFork(groupId, String(bs?.state_chain ?? ''));
|
|
5289
|
-
await this._v2VerifyStateSignature(groupId, bs);
|
|
5290
|
-
await this._publishV2GroupSecurityLevel(groupId, bs);
|
|
5291
|
-
stateCommitment = {
|
|
5292
|
-
state_version: Number(bs?.state_version ?? 0) || 0,
|
|
5293
|
-
state_hash: String(bs?.state_hash_signed ?? bs?.state_hash ?? ''),
|
|
5294
|
-
state_chain: String(bs?.state_chain ?? ''),
|
|
5295
|
-
};
|
|
5296
|
-
if (allDevices.length > 0) {
|
|
5297
|
-
this._v2BootstrapCache.set(cacheKey, {
|
|
5298
|
-
devices: allDevices,
|
|
5299
|
-
auditRecipients: auditRecipientsRaw,
|
|
5300
|
-
cachedAt: Date.now(),
|
|
5301
|
-
epoch,
|
|
5302
|
-
stateCommitment: stateCommitment,
|
|
5303
|
-
wrapPolicy,
|
|
5304
|
-
});
|
|
5305
|
-
}
|
|
5306
|
-
// lazy sync 触发:发现 pending members 时异步发起提案
|
|
5307
|
-
const pendingAdds = Array.isArray(bs?.pending_adds) ? bs.pending_adds : [];
|
|
5308
|
-
if (pendingAdds.length > 0 && this._v2Session) {
|
|
5309
|
-
this._v2MaybeTriggerAutoPropose(groupId);
|
|
5310
|
-
}
|
|
5311
|
-
}
|
|
5312
|
-
if (allDevices.length === 0) {
|
|
5313
|
-
throw new E2EEError(`V2 group bootstrap: no devices for ${groupId}`);
|
|
5314
|
-
}
|
|
5315
|
-
const targets = [];
|
|
5316
|
-
for (const dev of allDevices) {
|
|
5317
|
-
const devAid = String(dev.aid ?? '');
|
|
5318
|
-
const devId = getV2DeviceId(dev);
|
|
5319
|
-
if (devAid === this._aid && devId.present && devId.value === this._deviceId)
|
|
5320
|
-
continue;
|
|
5321
|
-
const role = devAid === this._aid ? 'self_sync' : 'member';
|
|
5322
|
-
const target = await this._v2BuildTargetFromDevice({
|
|
5323
|
-
dev,
|
|
5324
|
-
aid: devAid,
|
|
5325
|
-
deviceId: devId.value,
|
|
5326
|
-
role,
|
|
5327
|
-
defaultKeySource: 'peer_device_prekey',
|
|
5328
|
-
});
|
|
5329
|
-
if (target)
|
|
5330
|
-
targets.push(target);
|
|
5331
|
-
}
|
|
5332
|
-
for (const dev of auditRecipientsRaw) {
|
|
5333
|
-
const target = await this._v2BuildTargetFromDevice({
|
|
5334
|
-
dev,
|
|
5335
|
-
aid: String(dev.aid ?? ''),
|
|
5336
|
-
deviceId: String(dev.device_id ?? ''),
|
|
5337
|
-
role: 'audit',
|
|
5338
|
-
defaultKeySource: 'peer_device_prekey',
|
|
5339
|
-
});
|
|
5340
|
-
if (target)
|
|
5341
|
-
targets.push(target);
|
|
5342
|
-
}
|
|
5343
|
-
if (targets.length === 0) {
|
|
5344
|
-
throw new E2EEError(`V2 group: no target devices for ${groupId}`);
|
|
5345
|
-
}
|
|
5346
|
-
const sender = await session.getSenderIdentity();
|
|
5347
|
-
const sendTargets = applyV2WrapPolicyToTargets(targets, wrapPolicy);
|
|
5348
|
-
const envelope = await encryptGroupMessage(sender, groupId, epoch, sendTargets, opts.payload, { messageId: opts.messageId, timestamp: opts.timestamp, protectedHeaders: opts.protectedHeaders, context: opts.context }, stateCommitment);
|
|
5349
|
-
return envelope;
|
|
5350
|
-
}
|
|
5351
|
-
async _publishV2GroupSecurityLevel(groupId, bootstrap) {
|
|
5352
|
-
const level = String(bootstrap.e2ee_security_level ?? '').trim() || 'end_to_end';
|
|
5353
|
-
const previous = this._v2GroupSecurityLevels.get(groupId);
|
|
5354
|
-
if (previous === level)
|
|
5355
|
-
return;
|
|
5356
|
-
this._v2GroupSecurityLevels.set(groupId, level);
|
|
5357
|
-
await this._dispatcher.publish('group.v2.security_level', {
|
|
5358
|
-
group_id: groupId,
|
|
5359
|
-
level,
|
|
5360
|
-
warning: String(bootstrap.e2ee_security_warning ?? ''),
|
|
5361
|
-
previous_level: previous ?? null,
|
|
5362
|
-
});
|
|
2726
|
+
return await this._v2E2EE.buildV2GroupEnvelope(opts);
|
|
5363
2727
|
}
|
|
5364
2728
|
/**
|
|
5365
2729
|
* V2 Group thought.put:多设备 wrap envelope。
|
|
5366
2730
|
* 与 Python `_put_group_thought_encrypted_v2` 对齐。
|
|
5367
2731
|
*/
|
|
5368
2732
|
async _putGroupThoughtEncryptedV2(params) {
|
|
5369
|
-
|
|
5370
|
-
const payload = isJsonObject(params.payload) ? params.payload : null;
|
|
5371
|
-
if (!groupId) {
|
|
5372
|
-
throw new ValidationError('group.thought.put requires group_id');
|
|
5373
|
-
}
|
|
5374
|
-
if (payload === null) {
|
|
5375
|
-
throw new ValidationError('group.thought.put payload must be an object when encrypt=true');
|
|
5376
|
-
}
|
|
5377
|
-
const thoughtId = String(params.thought_id ?? '') || `gt-${_uuidV4()}`;
|
|
5378
|
-
const timestamp = Number(params.timestamp ?? Date.now());
|
|
5379
|
-
const attempt = async (useCache) => {
|
|
5380
|
-
const envelopeContext = (params.context && typeof params.context === 'object' && !Array.isArray(params.context))
|
|
5381
|
-
? params.context : undefined;
|
|
5382
|
-
const envelope = await this._buildV2GroupEnvelope({
|
|
5383
|
-
groupId,
|
|
5384
|
-
payload,
|
|
5385
|
-
messageId: thoughtId,
|
|
5386
|
-
timestamp,
|
|
5387
|
-
useCache,
|
|
5388
|
-
context: envelopeContext,
|
|
5389
|
-
});
|
|
5390
|
-
const sendParams = {
|
|
5391
|
-
group_id: groupId,
|
|
5392
|
-
payload: envelope,
|
|
5393
|
-
encrypted: true,
|
|
5394
|
-
thought_id: thoughtId,
|
|
5395
|
-
timestamp,
|
|
5396
|
-
};
|
|
5397
|
-
if ('context' in params)
|
|
5398
|
-
sendParams.context = params.context;
|
|
5399
|
-
await this._signClientOperation('group.thought.put', sendParams);
|
|
5400
|
-
return this._transport.call('group.thought.put', sendParams);
|
|
5401
|
-
};
|
|
5402
|
-
try {
|
|
5403
|
-
return await attempt(true);
|
|
5404
|
-
}
|
|
5405
|
-
catch (exc) {
|
|
5406
|
-
const excCode = exc?.code;
|
|
5407
|
-
if (AUNClient.V2_RETRYABLE_CODES.has(excCode)) {
|
|
5408
|
-
this._clientLog.debug(`V2 group thought put speculative rejected (code=${excCode}), refreshing bootstrap`);
|
|
5409
|
-
this._v2BootstrapCache.delete(`group:${groupId}`);
|
|
5410
|
-
return attempt(false);
|
|
5411
|
-
}
|
|
5412
|
-
throw exc;
|
|
5413
|
-
}
|
|
2733
|
+
return await this._v2E2EE.putGroupThoughtEncryptedV2(params);
|
|
5414
2734
|
}
|
|
5415
2735
|
/**
|
|
5416
2736
|
* 解密一个 V2 thought envelope(P2P 或 Group),返回 payload dict。
|
|
@@ -5418,656 +2738,60 @@ export class AUNClient {
|
|
|
5418
2738
|
* 与 Python `_decrypt_v2_envelope_for_thought` 对齐。
|
|
5419
2739
|
*/
|
|
5420
2740
|
async _decryptV2EnvelopeForThought(opts) {
|
|
5421
|
-
|
|
5422
|
-
if (!session || !opts.envelope)
|
|
5423
|
-
return null;
|
|
5424
|
-
// 找到本设备所引用的 spk_id 和 key_source:根据 envelope.recipients 中 [aid, device_id, ..., spk_id]
|
|
5425
|
-
let spkId = '';
|
|
5426
|
-
let recipientKeySource = '';
|
|
5427
|
-
const recipients = opts.envelope.recipients;
|
|
5428
|
-
if (Array.isArray(recipients)) {
|
|
5429
|
-
for (const row of recipients) {
|
|
5430
|
-
if (Array.isArray(row) && row.length >= 6) {
|
|
5431
|
-
if (row[0] === this._aid && (row[1] === this._deviceId || row[1] === '')) {
|
|
5432
|
-
spkId = String(row[5] ?? '');
|
|
5433
|
-
recipientKeySource = row.length > 3 ? String(row[3] ?? '') : '';
|
|
5434
|
-
break;
|
|
5435
|
-
}
|
|
5436
|
-
}
|
|
5437
|
-
}
|
|
5438
|
-
}
|
|
5439
|
-
const aad = opts.envelope.aad ?? {};
|
|
5440
|
-
const groupIdForKeys = String(aad.group_id ?? opts.envelope.group_id ?? '').trim();
|
|
5441
|
-
let ikPriv;
|
|
5442
|
-
let spkPriv;
|
|
5443
|
-
// group_id 只表示群上下文;group lookup 内部按 group SPK -> P2P device SPK -> IK fallback。
|
|
5444
|
-
try {
|
|
5445
|
-
if (groupIdForKeys) {
|
|
5446
|
-
const keys = await session.getGroupDecryptKeys(groupIdForKeys, spkId);
|
|
5447
|
-
ikPriv = keys.ikPriv;
|
|
5448
|
-
spkPriv = keys.spkPriv;
|
|
5449
|
-
}
|
|
5450
|
-
else {
|
|
5451
|
-
const keys = await session.getDecryptKeys(spkId);
|
|
5452
|
-
ikPriv = keys.ikPriv;
|
|
5453
|
-
spkPriv = keys.spkPriv;
|
|
5454
|
-
}
|
|
5455
|
-
}
|
|
5456
|
-
catch (exc) {
|
|
5457
|
-
this._clientLog.warn(`V2 thought decrypt: SPK lookup failed from=${opts.fromAid}, group=${groupIdForKeys || '<p2p>'}, spk_id=${spkId || '<empty>'}: ${exc}`);
|
|
5458
|
-
return null;
|
|
5459
|
-
}
|
|
5460
|
-
const fromAid = String(opts.fromAid || aad.from || '').trim();
|
|
5461
|
-
const senderDeviceId = String(aad.from_device ?? '');
|
|
5462
|
-
const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId);
|
|
5463
|
-
if (!senderPubDer) {
|
|
5464
|
-
this._clientLog.warn(`V2 thought decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
|
|
5465
|
-
this._scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupIdForKeys);
|
|
5466
|
-
return null;
|
|
5467
|
-
}
|
|
5468
|
-
try {
|
|
5469
|
-
const plaintext = await decryptMessage(opts.envelope, this._aid ?? '', this._deviceId, ikPriv, spkPriv, senderPubDer);
|
|
5470
|
-
// 消费触发 SPK 轮换(与 _decryptV2Message 对齐)
|
|
5471
|
-
if (plaintext != null) {
|
|
5472
|
-
if (groupIdForKeys && recipientKeySource === 'group_device_prekey' && session.isLastUploadedGroupSPK(groupIdForKeys, spkId)) {
|
|
5473
|
-
const callFn = async (method, params) => this.call(method, params);
|
|
5474
|
-
session.rotateGroupSPK(groupIdForKeys, callFn).catch(exc => {
|
|
5475
|
-
this._clientLog.debug(`V2 thought group SPK rotation failed (non-fatal): group=${groupIdForKeys} err=${exc}`);
|
|
5476
|
-
});
|
|
5477
|
-
}
|
|
5478
|
-
else if (groupIdForKeys && recipientKeySource === 'peer_device_prekey') {
|
|
5479
|
-
const callFn = async (method, params) => this.call(method, params);
|
|
5480
|
-
session.ensureGroupRegistered(groupIdForKeys, callFn).catch(exc => {
|
|
5481
|
-
this._clientLog.debug(`V2 thought group SPK registration after peer fallback failed (non-fatal): group=${groupIdForKeys} err=${exc}`);
|
|
5482
|
-
});
|
|
5483
|
-
}
|
|
5484
|
-
}
|
|
5485
|
-
return plaintext;
|
|
5486
|
-
}
|
|
5487
|
-
catch (exc) {
|
|
5488
|
-
this._clientLog.warn(`V2 thought decrypt failed from=${fromAid}: ${String(exc)}`);
|
|
5489
|
-
return null;
|
|
5490
|
-
}
|
|
2741
|
+
return await this._v2E2EE.decryptV2EnvelopeForThought(opts);
|
|
5491
2742
|
}
|
|
5492
2743
|
// ── V2 State 验签 / Fork 检测 / Auto-propose(Phase 3b.3 + 3b.4)──────
|
|
2744
|
+
async _publishV2GroupSecurityLevel(groupId, bootstrap) {
|
|
2745
|
+
return this._groupState.publishV2GroupSecurityLevel(groupId, bootstrap);
|
|
2746
|
+
}
|
|
5493
2747
|
/**
|
|
5494
2748
|
* 验证 owner/admin 对 state 的 ECDSA 签名(防服务端篡改 bootstrap 字段)。
|
|
5495
2749
|
* 与 Python SDK 对齐:证书获取或验签失败时拒绝信任 bootstrap。
|
|
5496
2750
|
*/
|
|
5497
2751
|
async _v2VerifyStateSignature(groupId, bootstrap) {
|
|
5498
|
-
|
|
5499
|
-
return;
|
|
5500
|
-
const stateSignature = String(bootstrap.state_signature ?? '');
|
|
5501
|
-
const actorAid = String(bootstrap.state_actor_aid ?? '');
|
|
5502
|
-
const stateHashSigned = String(bootstrap.state_hash_signed ?? '');
|
|
5503
|
-
const membershipSnapshot = String(bootstrap.state_membership_snapshot ?? '');
|
|
5504
|
-
const stateVersion = Number(bootstrap.state_version ?? 0) || 0;
|
|
5505
|
-
if (stateVersion === 0 || !stateSignature || !actorAid)
|
|
5506
|
-
return;
|
|
5507
|
-
try {
|
|
5508
|
-
// 重构签名 payload(与服务端 propose_state 中的格式一致)
|
|
5509
|
-
const signPayloadObj = {
|
|
5510
|
-
group_id: groupId,
|
|
5511
|
-
membership_snapshot: membershipSnapshot,
|
|
5512
|
-
state_hash: stateHashSigned,
|
|
5513
|
-
state_version: stateVersion,
|
|
5514
|
-
};
|
|
5515
|
-
const signPayload = stableStringify(signPayloadObj);
|
|
5516
|
-
const signPayloadBytes = new TextEncoder().encode(signPayload);
|
|
5517
|
-
const sigBytes = base64ToUint8(stateSignature);
|
|
5518
|
-
// 验签缓存检查
|
|
5519
|
-
const cacheData = _v2LengthPrefixedBytes(new TextEncoder().encode(actorAid), signPayloadBytes, sigBytes);
|
|
5520
|
-
const cacheHashBuf = await crypto.subtle.digest('SHA-256', cacheData.slice().buffer);
|
|
5521
|
-
const cacheHashArr = new Uint8Array(cacheHashBuf);
|
|
5522
|
-
let cacheKey = '';
|
|
5523
|
-
for (let i = 0; i < cacheHashArr.length; i++)
|
|
5524
|
-
cacheKey += cacheHashArr[i].toString(16).padStart(2, '0');
|
|
5525
|
-
const now = Date.now();
|
|
5526
|
-
const cachedExp = this._v2SigCache.get(cacheKey);
|
|
5527
|
-
if (cachedExp !== undefined && cachedExp > now) {
|
|
5528
|
-
this._clientLog.debug(`V2 state signature cache hit: group=${groupId} sv=${stateVersion}`);
|
|
5529
|
-
}
|
|
5530
|
-
else {
|
|
5531
|
-
// 获取 actor 证书(通过 HTTP PKI 端点,不走 ca.get_cert RPC)
|
|
5532
|
-
const certPem = await this._fetchPeerCert(actorAid);
|
|
5533
|
-
if (!certPem) {
|
|
5534
|
-
this._clientLog.warn(`V2 state verify: no cert for actor=${actorAid}, group=${groupId}`);
|
|
5535
|
-
throw new E2EEError(`V2 state verify: cannot fetch actor cert for ${actorAid}`);
|
|
5536
|
-
}
|
|
5537
|
-
const pubKey = await importCertPublicKeyEcdsa(certPem);
|
|
5538
|
-
const ok = await ecdsaVerifyDer(pubKey, sigBytes, signPayloadBytes);
|
|
5539
|
-
if (!ok) {
|
|
5540
|
-
this._clientLog.warn(`V2 state signature verification FAILED: group=${groupId} sv=${stateVersion} actor=${actorAid}`);
|
|
5541
|
-
throw new E2EEError('V2 state signature verification failed');
|
|
5542
|
-
}
|
|
5543
|
-
// 写入缓存
|
|
5544
|
-
this._v2SigCache.set(cacheKey, now + AUNClient._V2_SIG_CACHE_TTL);
|
|
5545
|
-
if (this._v2SigCache.size > AUNClient._V2_SIG_CACHE_MAX) {
|
|
5546
|
-
const stale = [];
|
|
5547
|
-
for (const [k, exp] of this._v2SigCache) {
|
|
5548
|
-
if (exp <= now)
|
|
5549
|
-
stale.push(k);
|
|
5550
|
-
}
|
|
5551
|
-
for (const k of stale)
|
|
5552
|
-
this._v2SigCache.delete(k);
|
|
5553
|
-
if (this._v2SigCache.size > AUNClient._V2_SIG_CACHE_MAX) {
|
|
5554
|
-
// 淘汰最旧的 1/4
|
|
5555
|
-
const entries = [...this._v2SigCache.entries()].sort((a, b) => a[1] - b[1]);
|
|
5556
|
-
const evictCount = Math.floor(AUNClient._V2_SIG_CACHE_MAX / 4);
|
|
5557
|
-
for (let i = 0; i < evictCount && i < entries.length; i++) {
|
|
5558
|
-
this._v2SigCache.delete(entries[i][0]);
|
|
5559
|
-
}
|
|
5560
|
-
}
|
|
5561
|
-
}
|
|
5562
|
-
this._clientLog.debug(`V2 state signature verified: group=${groupId} sv=${stateVersion} actor=${actorAid}`);
|
|
5563
|
-
}
|
|
5564
|
-
// 验证 member_aids 在签名快照中(best-effort)
|
|
5565
|
-
try {
|
|
5566
|
-
if (membershipSnapshot.startsWith('[')) {
|
|
5567
|
-
const signedSnapshot = JSON.parse(membershipSnapshot);
|
|
5568
|
-
const serverMembers = new Set(Array.isArray(bootstrap.member_aids) ? bootstrap.member_aids : []);
|
|
5569
|
-
const signedMembers = new Set(signedSnapshot);
|
|
5570
|
-
const extra = [];
|
|
5571
|
-
for (const m of serverMembers) {
|
|
5572
|
-
if (!signedMembers.has(m))
|
|
5573
|
-
extra.push(m);
|
|
5574
|
-
}
|
|
5575
|
-
if (extra.length > 0) {
|
|
5576
|
-
let mode = '';
|
|
5577
|
-
try {
|
|
5578
|
-
const reqResp = await this.call('group.get_join_requirements', { group_id: groupId });
|
|
5579
|
-
mode = isJsonObject(reqResp)
|
|
5580
|
-
? String(reqResp.mode ?? '').trim()
|
|
5581
|
-
: '';
|
|
5582
|
-
}
|
|
5583
|
-
catch {
|
|
5584
|
-
mode = '';
|
|
5585
|
-
}
|
|
5586
|
-
if (mode !== 'open' && mode !== 'invite_code' && mode !== 'invite_only') {
|
|
5587
|
-
this._clientLog.warn(`V2 state tamper detected: group=${groupId} pending_extra=${extra.sort().join(',')} mode=${mode}`);
|
|
5588
|
-
await this._dispatcher.publish('group.v2.state_tampered', {
|
|
5589
|
-
group_id: groupId,
|
|
5590
|
-
pending_extra: extra.sort(),
|
|
5591
|
-
mode,
|
|
5592
|
-
});
|
|
5593
|
-
}
|
|
5594
|
-
}
|
|
5595
|
-
}
|
|
5596
|
-
}
|
|
5597
|
-
catch {
|
|
5598
|
-
// snapshot 解析失败不阻断
|
|
5599
|
-
}
|
|
5600
|
-
}
|
|
5601
|
-
catch (exc) {
|
|
5602
|
-
if (exc instanceof E2EEError)
|
|
5603
|
-
throw exc;
|
|
5604
|
-
this._clientLog.warn(`V2 state signature verification failed: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
5605
|
-
throw new E2EEError(`V2 state signature verification failed: ${String(formatCaughtError(exc))}`);
|
|
5606
|
-
}
|
|
2752
|
+
return this._groupState.verifyStateSignature(groupId, bootstrap);
|
|
5607
2753
|
}
|
|
5608
2754
|
/**
|
|
5609
2755
|
* 分叉检测:比对服务端 state_chain 与本地存储。
|
|
5610
2756
|
*/
|
|
5611
2757
|
async _v2CheckFork(groupId, serverChain) {
|
|
5612
|
-
|
|
5613
|
-
return;
|
|
5614
|
-
try {
|
|
5615
|
-
const local = this._v2StateChains.get(groupId);
|
|
5616
|
-
if (local === undefined) {
|
|
5617
|
-
this._v2StateChains.set(groupId, [0, serverChain]);
|
|
5618
|
-
return;
|
|
5619
|
-
}
|
|
5620
|
-
const [localSv, localChain] = local;
|
|
5621
|
-
if (localChain === serverChain)
|
|
5622
|
-
return;
|
|
5623
|
-
// 不一致:尝试通过 get_state 判断是正常推进还是分叉
|
|
5624
|
-
try {
|
|
5625
|
-
const stateResp = await this.call('group.get_state', { group_id: groupId });
|
|
5626
|
-
if (stateResp) {
|
|
5627
|
-
const serverSv = Number(stateResp.state_version ?? 0);
|
|
5628
|
-
if (serverSv > localSv) {
|
|
5629
|
-
// 正常推进
|
|
5630
|
-
this._v2StateChains.set(groupId, [serverSv, serverChain]);
|
|
5631
|
-
return;
|
|
5632
|
-
}
|
|
5633
|
-
if (serverSv < localSv) {
|
|
5634
|
-
this._clientLog.warn(`V2 state chain rollback detected: group=${groupId} server_sv=${serverSv} local_sv=${localSv}`);
|
|
5635
|
-
}
|
|
5636
|
-
}
|
|
5637
|
-
}
|
|
5638
|
-
catch {
|
|
5639
|
-
// get_state 失败不阻断
|
|
5640
|
-
}
|
|
5641
|
-
// 告警:分叉
|
|
5642
|
-
this._clientLog.warn(`V2 state chain fork detected: group=${groupId} local_chain=${localChain.slice(0, 16)}... server_chain=${serverChain.slice(0, 16)}...`);
|
|
5643
|
-
this._dispatcher.publish('group.v2.fork_detected', {
|
|
5644
|
-
group_id: groupId,
|
|
5645
|
-
local_chain: localChain,
|
|
5646
|
-
server_chain: serverChain,
|
|
5647
|
-
});
|
|
5648
|
-
}
|
|
5649
|
-
catch (exc) {
|
|
5650
|
-
this._clientLog.debug(`V2 fork check failed (non-fatal): ${exc}`);
|
|
5651
|
-
}
|
|
2758
|
+
return this._groupState.checkFork(groupId, serverChain);
|
|
5652
2759
|
}
|
|
5653
2760
|
_v2MaybeTriggerAutoPropose(groupId) {
|
|
5654
|
-
|
|
5655
|
-
const last = this._v2LazyProposeTriggered.get(groupId) ?? 0;
|
|
5656
|
-
if (now - last < 10000)
|
|
5657
|
-
return;
|
|
5658
|
-
this._v2LazyProposeTriggered.set(groupId, now);
|
|
5659
|
-
this._safeAsync(this._v2AutoProposeState(groupId, { leaderDelay: true }));
|
|
2761
|
+
this._groupState.maybeTriggerAutoPropose(groupId);
|
|
5660
2762
|
}
|
|
5661
2763
|
/**
|
|
5662
2764
|
* 成员变更后自动 propose state(仅 owner/admin 执行)。
|
|
5663
2765
|
*/
|
|
5664
2766
|
async _v2AutoProposeState(groupId, options) {
|
|
5665
|
-
|
|
5666
|
-
if (!normalizedGroupId)
|
|
5667
|
-
return;
|
|
5668
|
-
if (options?.leaderDelay) {
|
|
5669
|
-
const shouldContinue = await this._v2AutoProposeLeaderDelay(normalizedGroupId);
|
|
5670
|
-
if (!shouldContinue)
|
|
5671
|
-
return;
|
|
5672
|
-
}
|
|
5673
|
-
const inflight = this._v2AutoProposeInflight.get(normalizedGroupId);
|
|
5674
|
-
if (inflight) {
|
|
5675
|
-
this._v2AutoProposePending.add(normalizedGroupId);
|
|
5676
|
-
await inflight;
|
|
5677
|
-
return;
|
|
5678
|
-
}
|
|
5679
|
-
let resolveTask;
|
|
5680
|
-
let rejectTask;
|
|
5681
|
-
const task = new Promise((resolve, reject) => {
|
|
5682
|
-
resolveTask = resolve;
|
|
5683
|
-
rejectTask = reject;
|
|
5684
|
-
});
|
|
5685
|
-
this._v2AutoProposeInflight.set(normalizedGroupId, task);
|
|
5686
|
-
void (async () => {
|
|
5687
|
-
try {
|
|
5688
|
-
do {
|
|
5689
|
-
this._v2AutoProposePending.delete(normalizedGroupId);
|
|
5690
|
-
await this._doV2AutoProposeState(normalizedGroupId);
|
|
5691
|
-
} while (this._v2AutoProposePending.delete(normalizedGroupId));
|
|
5692
|
-
resolveTask();
|
|
5693
|
-
}
|
|
5694
|
-
catch (exc) {
|
|
5695
|
-
rejectTask(exc);
|
|
5696
|
-
}
|
|
5697
|
-
finally {
|
|
5698
|
-
if (this._v2AutoProposeInflight.get(normalizedGroupId) === task) {
|
|
5699
|
-
this._v2AutoProposeInflight.delete(normalizedGroupId);
|
|
5700
|
-
}
|
|
5701
|
-
this._v2AutoProposePending.delete(normalizedGroupId);
|
|
5702
|
-
}
|
|
5703
|
-
})();
|
|
5704
|
-
try {
|
|
5705
|
-
await task;
|
|
5706
|
-
}
|
|
5707
|
-
finally {
|
|
5708
|
-
if (this._v2AutoProposeInflight.get(normalizedGroupId) === task) {
|
|
5709
|
-
this._v2AutoProposeInflight.delete(normalizedGroupId);
|
|
5710
|
-
}
|
|
5711
|
-
this._v2AutoProposePending.delete(normalizedGroupId);
|
|
5712
|
-
}
|
|
2767
|
+
return this._groupState.autoProposeState(groupId, options);
|
|
5713
2768
|
}
|
|
5714
2769
|
async _v2LeaderDelayMs(input) {
|
|
5715
|
-
|
|
5716
|
-
const first32 = new DataView(digest.buffer, digest.byteOffset, digest.byteLength)
|
|
5717
|
-
.getUint32(0, false);
|
|
5718
|
-
return 2000 + (first32 % 4000);
|
|
2770
|
+
return this._groupState.leaderDelayMs(input);
|
|
5719
2771
|
}
|
|
5720
2772
|
async _v2AutoProposeLeaderDelay(groupId) {
|
|
5721
|
-
|
|
5722
|
-
const membersResp = await this.call('group.get_online_members', { group_id: groupId });
|
|
5723
|
-
const members = (Array.isArray(membersResp?.members) ? membersResp.members
|
|
5724
|
-
: Array.isArray(membersResp?.items) ? membersResp.items
|
|
5725
|
-
: Array.isArray(membersResp?.online_members) ? membersResp.online_members : []);
|
|
5726
|
-
const myAid = this._aid ?? '';
|
|
5727
|
-
let myRole = '';
|
|
5728
|
-
const onlineAdminAids = new Set();
|
|
5729
|
-
for (const member of members) {
|
|
5730
|
-
const aid = String(member.aid ?? '').trim();
|
|
5731
|
-
const role = String(member.role ?? '').trim();
|
|
5732
|
-
if (!aid)
|
|
5733
|
-
continue;
|
|
5734
|
-
if ('online' in member && !Boolean(member.online))
|
|
5735
|
-
continue;
|
|
5736
|
-
if (role === 'owner' || role === 'admin')
|
|
5737
|
-
onlineAdminAids.add(aid);
|
|
5738
|
-
if (aid === myAid)
|
|
5739
|
-
myRole = role;
|
|
5740
|
-
}
|
|
5741
|
-
if (myRole !== 'owner' && myRole !== 'admin')
|
|
5742
|
-
return false;
|
|
5743
|
-
const bootstrapResp = await this.call('group.v2.bootstrap', {
|
|
5744
|
-
group_id: groupId,
|
|
5745
|
-
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
5746
|
-
});
|
|
5747
|
-
const devices = (Array.isArray(bootstrapResp?.devices) ? bootstrapResp.devices : []);
|
|
5748
|
-
const candidates = [];
|
|
5749
|
-
for (const dev of devices) {
|
|
5750
|
-
const aid = String(dev.aid ?? '').trim();
|
|
5751
|
-
const hasDeviceId = 'device_id' in dev;
|
|
5752
|
-
const deviceId = String(dev.device_id ?? '').trim();
|
|
5753
|
-
if (aid && hasDeviceId && onlineAdminAids.has(aid)) {
|
|
5754
|
-
candidates.push(`${aid}\x1f${deviceId}`);
|
|
5755
|
-
}
|
|
5756
|
-
}
|
|
5757
|
-
if (candidates.length === 0) {
|
|
5758
|
-
for (const aid of [...onlineAdminAids].sort())
|
|
5759
|
-
candidates.push(`${aid}\x1f`);
|
|
5760
|
-
}
|
|
5761
|
-
const myKey = `${myAid}\x1f${this._deviceId ?? ''}`;
|
|
5762
|
-
if (!candidates.includes(myKey))
|
|
5763
|
-
candidates.push(myKey);
|
|
5764
|
-
const leader = [...new Set(candidates)].sort()[0];
|
|
5765
|
-
if (leader === myKey) {
|
|
5766
|
-
this._clientLog.debug(`V2 auto propose leader elected: group=${groupId} leader=${leader}`);
|
|
5767
|
-
return true;
|
|
5768
|
-
}
|
|
5769
|
-
const delayMs = await this._v2LeaderDelayMs(_v2LengthPrefixedTextKey(groupId, myKey));
|
|
5770
|
-
this._clientLog.debug(`V2 auto propose non-leader delay: group=${groupId} leader=${leader} self=${myKey} delay_ms=${delayMs}`);
|
|
5771
|
-
await this._sleep(delayMs);
|
|
5772
|
-
return true;
|
|
5773
|
-
}
|
|
5774
|
-
catch (exc) {
|
|
5775
|
-
this._clientLog.debug(`V2 auto propose leader check failed, fallback immediate: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
5776
|
-
return true;
|
|
5777
|
-
}
|
|
5778
|
-
}
|
|
5779
|
-
async _v2VerifyCommittedStateBase(groupId, stateResp) {
|
|
5780
|
-
const currentSv = Number(stateResp.state_version ?? 0) || 0;
|
|
5781
|
-
if (currentSv <= 0)
|
|
5782
|
-
return true;
|
|
5783
|
-
const currentSh = String(stateResp.state_hash ?? '').trim();
|
|
5784
|
-
const membershipSnapshot = String(stateResp.membership_snapshot ?? '').trim();
|
|
5785
|
-
if (!currentSh || !membershipSnapshot) {
|
|
5786
|
-
this._clientLog.warn(`V2 committed state base incomplete: group=${groupId} sv=${currentSv}`);
|
|
5787
|
-
return false;
|
|
5788
|
-
}
|
|
5789
|
-
try {
|
|
5790
|
-
const parsed = JSON.parse(membershipSnapshot);
|
|
5791
|
-
if (!isJsonObject(parsed)) {
|
|
5792
|
-
this._clientLog.warn(`V2 committed state base snapshot is not object: group=${groupId} sv=${currentSv}`);
|
|
5793
|
-
return false;
|
|
5794
|
-
}
|
|
5795
|
-
const computed = await computeStateCommitment(groupId, currentSv, parsed);
|
|
5796
|
-
if (computed !== currentSh) {
|
|
5797
|
-
this._clientLog.warn(`V2 committed state base hash mismatch: group=${groupId} sv=${currentSv}`);
|
|
5798
|
-
return false;
|
|
5799
|
-
}
|
|
5800
|
-
return true;
|
|
5801
|
-
}
|
|
5802
|
-
catch (exc) {
|
|
5803
|
-
this._clientLog.warn(`V2 committed state base verification failed: group=${groupId} sv=${currentSv} err=${formatCaughtError(exc)}`);
|
|
5804
|
-
return false;
|
|
5805
|
-
}
|
|
2773
|
+
return this._groupState.autoProposeLeaderDelay(groupId);
|
|
5806
2774
|
}
|
|
5807
2775
|
async _doV2AutoProposeState(groupId) {
|
|
5808
|
-
|
|
5809
|
-
// 获取当前成员列表 + 角色
|
|
5810
|
-
const membersResp = await this.call('group.get_members', { group_id: groupId });
|
|
5811
|
-
const members = (Array.isArray(membersResp?.members) ? membersResp.members
|
|
5812
|
-
: Array.isArray(membersResp?.items) ? membersResp.items : []);
|
|
5813
|
-
const myAid = this._aid ?? '';
|
|
5814
|
-
let myRole = '';
|
|
5815
|
-
const memberAids = [];
|
|
5816
|
-
const adminAids = [];
|
|
5817
|
-
for (const m of members) {
|
|
5818
|
-
const aid = String(m.aid ?? '').trim();
|
|
5819
|
-
const role = String(m.role ?? '').trim();
|
|
5820
|
-
if (aid) {
|
|
5821
|
-
memberAids.push(aid);
|
|
5822
|
-
if (role === 'owner' || role === 'admin')
|
|
5823
|
-
adminAids.push(aid);
|
|
5824
|
-
}
|
|
5825
|
-
if (aid === myAid)
|
|
5826
|
-
myRole = role;
|
|
5827
|
-
}
|
|
5828
|
-
if (myRole !== 'owner' && myRole !== 'admin')
|
|
5829
|
-
return;
|
|
5830
|
-
// 前置检查:如果已有 pending proposal,先尝试 confirm 而非重复 propose
|
|
5831
|
-
const proposalResp = await this.call('group.v2.get_proposal', { group_id: groupId });
|
|
5832
|
-
if (proposalResp && typeof proposalResp === 'object') {
|
|
5833
|
-
const pendingProposal = proposalResp.proposal;
|
|
5834
|
-
if (pendingProposal && typeof pendingProposal === 'object' && String(pendingProposal.proposal_id ?? '').trim()) {
|
|
5835
|
-
const confirmed = await this._v2ConfirmPendingProposal(groupId);
|
|
5836
|
-
if (confirmed)
|
|
5837
|
-
return;
|
|
5838
|
-
const autoConfirmAt = Number(pendingProposal.auto_confirm_at ?? 0) || 0;
|
|
5839
|
-
const nowMs = Date.now();
|
|
5840
|
-
if (autoConfirmAt > nowMs) {
|
|
5841
|
-
const waitMs = Math.min(autoConfirmAt - nowMs + 500, 35000);
|
|
5842
|
-
this._clientLog.debug(`V2 auto propose: pending proposal exists, waiting ${waitMs}ms group=${groupId}`);
|
|
5843
|
-
await new Promise((r) => setTimeout(r, waitMs));
|
|
5844
|
-
}
|
|
5845
|
-
}
|
|
5846
|
-
}
|
|
5847
|
-
// 获取群所有成员的设备列表(V2 bootstrap)
|
|
5848
|
-
const bootstrapResp = await this.call('group.v2.bootstrap', {
|
|
5849
|
-
group_id: groupId,
|
|
5850
|
-
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
5851
|
-
});
|
|
5852
|
-
const allDevices = (Array.isArray(bootstrapResp?.devices) ? bootstrapResp.devices : []);
|
|
5853
|
-
const auditRecipients = (Array.isArray(bootstrapResp?.audit_recipients) ? bootstrapResp.audit_recipients : []);
|
|
5854
|
-
const auditAidsList = [...new Set(auditRecipients.map(r => String(r.aid ?? '').trim()).filter(Boolean))].sort();
|
|
5855
|
-
// 按 aid 分组设备
|
|
5856
|
-
const membersWithDevices = {};
|
|
5857
|
-
for (const aid of memberAids)
|
|
5858
|
-
membersWithDevices[aid] = [];
|
|
5859
|
-
for (const dev of allDevices) {
|
|
5860
|
-
const devAid = String(dev.aid ?? '').trim();
|
|
5861
|
-
if (devAid in membersWithDevices) {
|
|
5862
|
-
membersWithDevices[devAid].push({
|
|
5863
|
-
device_id: String(dev.device_id ?? ''),
|
|
5864
|
-
ik_fp: String(dev.ik_fp ?? ''),
|
|
5865
|
-
});
|
|
5866
|
-
}
|
|
5867
|
-
}
|
|
5868
|
-
const membersPayload = Object.entries(membersWithDevices).map(([aid, devices]) => ({
|
|
5869
|
-
aid,
|
|
5870
|
-
devices,
|
|
5871
|
-
}));
|
|
5872
|
-
const statePayload = {
|
|
5873
|
-
members: membersPayload,
|
|
5874
|
-
audit_aids: auditAidsList,
|
|
5875
|
-
admin_set: { admin_aids: adminAids.sort(), threshold: 1 },
|
|
5876
|
-
join_policy_hash: null,
|
|
5877
|
-
recovery_quorum: null,
|
|
5878
|
-
history_policy: 'recent_7_days',
|
|
5879
|
-
wrap_protocol: '3DH',
|
|
5880
|
-
};
|
|
5881
|
-
// 获取当前 state
|
|
5882
|
-
const stateResp = await this.call('group.get_state', { group_id: groupId });
|
|
5883
|
-
if (!stateResp)
|
|
5884
|
-
return;
|
|
5885
|
-
if (!isJsonObject(stateResp))
|
|
5886
|
-
return;
|
|
5887
|
-
if (!(await this._v2VerifyCommittedStateBase(groupId, stateResp)))
|
|
5888
|
-
return;
|
|
5889
|
-
const currentSv = Number(stateResp.state_version ?? 0);
|
|
5890
|
-
const currentSh = String(stateResp.state_hash ?? '');
|
|
5891
|
-
const keyEpoch = Number(stateResp.key_epoch ?? 0);
|
|
5892
|
-
// 用完整 commitment 算 state_hash
|
|
5893
|
-
const stateHash = await computeStateCommitment(groupId, currentSv + 1, statePayload);
|
|
5894
|
-
// 签名 state proposal
|
|
5895
|
-
const membershipSnapshot = stableStringify(statePayload);
|
|
5896
|
-
const lastMembershipSnapshot = this._v2AutoProposeLastSnapshot.get(groupId);
|
|
5897
|
-
if (lastMembershipSnapshot === membershipSnapshot) {
|
|
5898
|
-
return;
|
|
5899
|
-
}
|
|
5900
|
-
const currentMembershipSnapshot = String(stateResp.membership_snapshot ?? '');
|
|
5901
|
-
if (currentMembershipSnapshot && currentMembershipSnapshot === membershipSnapshot) {
|
|
5902
|
-
this._v2AutoProposeLastSnapshot.set(groupId, membershipSnapshot);
|
|
5903
|
-
return;
|
|
5904
|
-
}
|
|
5905
|
-
let signature = '';
|
|
5906
|
-
const currentAid = this._currentAid;
|
|
5907
|
-
if (currentAid?.privateKeyPem) {
|
|
5908
|
-
try {
|
|
5909
|
-
const signPayloadObj = {
|
|
5910
|
-
group_id: groupId,
|
|
5911
|
-
membership_snapshot: membershipSnapshot,
|
|
5912
|
-
state_hash: stateHash,
|
|
5913
|
-
state_version: currentSv + 1,
|
|
5914
|
-
};
|
|
5915
|
-
const signPayload = stableStringify(signPayloadObj);
|
|
5916
|
-
const signPayloadBytes = new TextEncoder().encode(signPayload);
|
|
5917
|
-
const privKey = await importPrivateKeyEcdsa(currentAid.privateKeyPem);
|
|
5918
|
-
const sigBytes = await ecdsaSignDer(privKey, signPayloadBytes);
|
|
5919
|
-
signature = uint8ToBase64(sigBytes);
|
|
5920
|
-
}
|
|
5921
|
-
catch (sigExc) {
|
|
5922
|
-
this._clientLog.debug(`propose_state signature failed: ${sigExc}`);
|
|
5923
|
-
}
|
|
5924
|
-
}
|
|
5925
|
-
const proposeResult = await this.call('group.v2.propose_state', {
|
|
5926
|
-
group_id: groupId,
|
|
5927
|
-
state_version: currentSv + 1,
|
|
5928
|
-
key_epoch: keyEpoch,
|
|
5929
|
-
state_hash: stateHash,
|
|
5930
|
-
prev_state_hash: currentSh,
|
|
5931
|
-
membership_snapshot: membershipSnapshot,
|
|
5932
|
-
signature,
|
|
5933
|
-
reason: 'membership_changed',
|
|
5934
|
-
auto_confirm_seconds: 30,
|
|
5935
|
-
});
|
|
5936
|
-
this._clientLog.debug(`V2 auto propose_state: group=${groupId} sv=${currentSv + 1}`);
|
|
5937
|
-
const proposalId = isJsonObject(proposeResult) ? String(proposeResult.proposal_id ?? '').trim() : '';
|
|
5938
|
-
if (proposalId) {
|
|
5939
|
-
try {
|
|
5940
|
-
await this.call('group.v2.confirm_state', { proposal_id: proposalId });
|
|
5941
|
-
this._v2AutoProposeLastSnapshot.set(groupId, membershipSnapshot);
|
|
5942
|
-
this._clientLog.debug(`V2 auto confirm_state: group=${groupId} proposal=${proposalId}`);
|
|
5943
|
-
}
|
|
5944
|
-
catch (confirmExc) {
|
|
5945
|
-
this._clientLog.debug(`V2 auto confirm_state failed (non-fatal): group=${groupId} err=${confirmExc}`);
|
|
5946
|
-
}
|
|
5947
|
-
}
|
|
5948
|
-
}
|
|
5949
|
-
catch (exc) {
|
|
5950
|
-
this._clientLog.debug(`V2 auto propose_state failed (non-fatal): group=${groupId} err=${exc}`);
|
|
5951
|
-
}
|
|
5952
|
-
}
|
|
5953
|
-
async _v2VerifyPendingProposalAgainstBase(groupId, proposal, stateResp) {
|
|
5954
|
-
if (!(await this._v2VerifyCommittedStateBase(groupId, stateResp)))
|
|
5955
|
-
return false;
|
|
5956
|
-
const currentSv = Number(stateResp.state_version ?? 0) || 0;
|
|
5957
|
-
const currentSh = String(stateResp.state_hash ?? '').trim();
|
|
5958
|
-
const proposalSv = Number(proposal.state_version ?? 0) || 0;
|
|
5959
|
-
const proposalHash = String(proposal.state_hash ?? '').trim();
|
|
5960
|
-
const proposalPrev = String(proposal.prev_state_hash ?? '').trim();
|
|
5961
|
-
const membershipSnapshot = String(proposal.membership_snapshot ?? '').trim();
|
|
5962
|
-
if (proposalSv !== currentSv + 1 || proposalPrev !== currentSh || !proposalHash || !membershipSnapshot) {
|
|
5963
|
-
this._clientLog.warn(`V2 pending proposal base mismatch: group=${groupId} current_sv=${currentSv} proposal_sv=${proposalSv}`);
|
|
5964
|
-
return false;
|
|
5965
|
-
}
|
|
5966
|
-
try {
|
|
5967
|
-
const parsed = JSON.parse(membershipSnapshot);
|
|
5968
|
-
if (!isJsonObject(parsed))
|
|
5969
|
-
return false;
|
|
5970
|
-
const computed = await computeStateCommitment(groupId, proposalSv, parsed);
|
|
5971
|
-
if (computed !== proposalHash) {
|
|
5972
|
-
this._clientLog.warn(`V2 pending proposal hash mismatch: group=${groupId} proposal_sv=${proposalSv}`);
|
|
5973
|
-
return false;
|
|
5974
|
-
}
|
|
5975
|
-
return true;
|
|
5976
|
-
}
|
|
5977
|
-
catch (exc) {
|
|
5978
|
-
this._clientLog.warn(`V2 pending proposal verification failed: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
5979
|
-
return false;
|
|
5980
|
-
}
|
|
2776
|
+
return this._groupState.doAutoProposeState(groupId);
|
|
5981
2777
|
}
|
|
5982
2778
|
async _v2ConfirmPendingProposal(groupId) {
|
|
5983
|
-
|
|
5984
|
-
const proposal = isJsonObject(proposalResp?.proposal) ? proposalResp.proposal : null;
|
|
5985
|
-
const proposalId = proposal ? String(proposal.proposal_id ?? '').trim() : '';
|
|
5986
|
-
if (!proposal || !proposalId)
|
|
5987
|
-
return false;
|
|
5988
|
-
const stateResp = await this.call('group.get_state', { group_id: groupId });
|
|
5989
|
-
if (!isJsonObject(stateResp))
|
|
5990
|
-
return false;
|
|
5991
|
-
const currentSv = Number(stateResp.state_version ?? 0) || 0;
|
|
5992
|
-
const proposalSv = Number(proposal.state_version ?? 0) || 0;
|
|
5993
|
-
if (proposalSv <= currentSv) {
|
|
5994
|
-
this._clientLog.debug(`V2 pending proposal already settled: group=${groupId} current_sv=${currentSv} proposal_sv=${proposalSv}`);
|
|
5995
|
-
return false;
|
|
5996
|
-
}
|
|
5997
|
-
if (!(await this._v2VerifyPendingProposalAgainstBase(groupId, proposal, stateResp)))
|
|
5998
|
-
return false;
|
|
5999
|
-
await this.call('group.v2.confirm_state', { proposal_id: proposalId });
|
|
6000
|
-
this._clientLog.info(`V2 confirmed pending proposal: group=${groupId} proposal=${proposalId}`);
|
|
6001
|
-
return true;
|
|
2779
|
+
return this._groupState.confirmPendingProposal(groupId);
|
|
6002
2780
|
}
|
|
6003
2781
|
/**
|
|
6004
2782
|
* Owner 上线时自动检查并签名确认 pending state proposals。
|
|
6005
2783
|
*/
|
|
6006
2784
|
async _v2AutoConfirmPendingProposals() {
|
|
6007
|
-
|
|
6008
|
-
const myAid = this._aid ?? '';
|
|
6009
|
-
if (!myAid)
|
|
6010
|
-
return;
|
|
6011
|
-
const groupsResp = await this.call('group.list_my', {});
|
|
6012
|
-
const groups = (Array.isArray(groupsResp?.groups) ? groupsResp.groups
|
|
6013
|
-
: Array.isArray(groupsResp?.items) ? groupsResp.items : []);
|
|
6014
|
-
for (const g of groups) {
|
|
6015
|
-
const gid = String(g.group_id ?? '').trim();
|
|
6016
|
-
const myRole = String(g.role ?? g.my_role ?? '').trim();
|
|
6017
|
-
if (!gid || (myRole !== 'owner' && myRole !== 'admin'))
|
|
6018
|
-
continue;
|
|
6019
|
-
try {
|
|
6020
|
-
const confirmed = await this._v2ConfirmPendingProposal(gid);
|
|
6021
|
-
if (!confirmed) {
|
|
6022
|
-
await this._v2AutoProposeState(gid);
|
|
6023
|
-
}
|
|
6024
|
-
}
|
|
6025
|
-
catch (exc) {
|
|
6026
|
-
this._clientLog.debug(`V2 auto confirm/propose failed (non-fatal): group=${gid} err=${exc}`);
|
|
6027
|
-
}
|
|
6028
|
-
}
|
|
6029
|
-
}
|
|
6030
|
-
catch (exc) {
|
|
6031
|
-
this._clientLog.debug(`V2 auto confirm pending proposals failed (non-fatal): ${exc}`);
|
|
6032
|
-
}
|
|
2785
|
+
return this._groupState.autoConfirmPendingProposals();
|
|
6033
2786
|
}
|
|
6034
2787
|
async _onV2StateProposed(data) {
|
|
6035
|
-
|
|
6036
|
-
return;
|
|
6037
|
-
const groupId = String(data.group_id ?? '').trim();
|
|
6038
|
-
if (!groupId)
|
|
6039
|
-
return;
|
|
6040
|
-
await this._dispatcher.publish('group.v2.state_proposed', data);
|
|
6041
|
-
try {
|
|
6042
|
-
await this._v2ConfirmPendingProposal(groupId);
|
|
6043
|
-
}
|
|
6044
|
-
catch (exc) {
|
|
6045
|
-
this._clientLog.debug(`V2 state_proposed handling failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
|
|
6046
|
-
}
|
|
2788
|
+
return this._groupState.onV2StateProposed(data);
|
|
6047
2789
|
}
|
|
6048
2790
|
async _onV2StateRetryNeeded(data) {
|
|
6049
|
-
|
|
6050
|
-
return;
|
|
6051
|
-
const groupId = String(data.group_id ?? '').trim();
|
|
6052
|
-
if (!groupId)
|
|
6053
|
-
return;
|
|
6054
|
-
await this._dispatcher.publish('group.v2.state_retry_needed', data);
|
|
6055
|
-
try {
|
|
6056
|
-
await this._v2AutoProposeState(groupId, { leaderDelay: true });
|
|
6057
|
-
}
|
|
6058
|
-
catch (exc) {
|
|
6059
|
-
this._clientLog.debug(`V2 state_retry_needed handling failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
|
|
6060
|
-
}
|
|
2791
|
+
return this._groupState.onV2StateRetryNeeded(data);
|
|
6061
2792
|
}
|
|
6062
2793
|
async _onV2StateConfirmed(data) {
|
|
6063
|
-
|
|
6064
|
-
return;
|
|
6065
|
-
const groupId = String(data.group_id ?? '').trim();
|
|
6066
|
-
if (groupId) {
|
|
6067
|
-
this._v2BootstrapCache.delete(`group:${groupId}`);
|
|
6068
|
-
this._v2AutoProposeLastSnapshot.delete(groupId);
|
|
6069
|
-
}
|
|
6070
|
-
await this._dispatcher.publish('group.v2.state_confirmed', data);
|
|
2794
|
+
return this._groupState.onV2StateConfirmed(data);
|
|
6071
2795
|
}
|
|
6072
2796
|
/**
|
|
6073
2797
|
* 处理 V2 push 通知:自动 pull + decrypt + emit。
|
|
@@ -6075,116 +2799,13 @@ export class AUNClient {
|
|
|
6075
2799
|
_v2PullInflight = false;
|
|
6076
2800
|
_v2PullPending = false;
|
|
6077
2801
|
async _onV2PushNotification(data) {
|
|
6078
|
-
|
|
6079
|
-
return;
|
|
6080
|
-
// 提取 push 通知中的元数据
|
|
6081
|
-
const pushSeq = isJsonObject(data) ? Number(data.seq ?? 0) || 0 : 0;
|
|
6082
|
-
const pushFrom = isJsonObject(data) ? String(data.from_aid ?? '') : '';
|
|
6083
|
-
const pushMsgId = isJsonObject(data) ? String(data.message_id ?? '') : '';
|
|
6084
|
-
const envelopeJson = isJsonObject(data) ? data.envelope_json : undefined;
|
|
6085
|
-
const hasPayload = !!envelopeJson;
|
|
6086
|
-
const ns = this._aid ? `p2p:${this._aid}` : '';
|
|
6087
|
-
let contigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
|
|
6088
|
-
this._clientLog.debug(`_onV2PushNotification: push_seq=${pushSeq || 'null'} push_from=${pushFrom} push_msg_id=${pushMsgId} has_payload=${hasPayload} contiguous_seq=${contigBefore}`);
|
|
6089
|
-
// ── Push 修上界:只更新 maxSeenSeq,不动 contiguousSeq ──
|
|
6090
|
-
if (pushSeq > 0 && ns) {
|
|
6091
|
-
this._seqTracker.updateMaxSeen(ns, pushSeq);
|
|
6092
|
-
if (contigBefore === pushSeq) {
|
|
6093
|
-
this._clientLog.debug(`_onV2PushNotification: push seq=${pushSeq} already covered by contiguous_seq=${contigBefore}, ignore duplicate push`);
|
|
6094
|
-
return;
|
|
6095
|
-
}
|
|
6096
|
-
contigBefore = this._repairPushContiguousBound(ns, pushSeq, hasPayload, '_raw.peer.v2.message_received');
|
|
6097
|
-
}
|
|
6098
|
-
// ── 带 payload 的 push:尝试就地解密 ──
|
|
6099
|
-
if (hasPayload && pushSeq > 0 && ns) {
|
|
6100
|
-
try {
|
|
6101
|
-
const decrypted = await this._decryptV2Message(data);
|
|
6102
|
-
if (decrypted) {
|
|
6103
|
-
// 解密成功:把 pushSeq 加入 receivedSeqs,让 _tryAdvance 自然推进
|
|
6104
|
-
const needPull = this._seqTracker.onMessageSeq(ns, pushSeq);
|
|
6105
|
-
const published = await this._publishOrderedMessage('message.received', ns, pushSeq, decrypted);
|
|
6106
|
-
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
6107
|
-
if (newContig !== contigBefore) {
|
|
6108
|
-
this._saveSeqTrackerState();
|
|
6109
|
-
}
|
|
6110
|
-
if (newContig > 0 && newContig !== contigBefore) {
|
|
6111
|
-
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
6112
|
-
const ackSeq = maxSeen > 0 ? Math.min(newContig, maxSeen) : newContig;
|
|
6113
|
-
this._callRawV2Rpc('message.v2.ack', { up_to_seq: ackSeq })
|
|
6114
|
-
.catch(e => this._clientLog.debug(`V2 P2P push-ack failed: ${e}`));
|
|
6115
|
-
}
|
|
6116
|
-
this._clientLog.debug(`_onV2PushNotification: push 带 payload 解密成功, contiguous_seq=${contigBefore}->${newContig} push_seq=${pushSeq}`);
|
|
6117
|
-
if (!needPull && (published || newContig >= pushSeq || pushSeq <= contigBefore)) {
|
|
6118
|
-
return;
|
|
6119
|
-
}
|
|
6120
|
-
this._clientLog.debug(`_onV2PushNotification: payload push seq=${pushSeq} 因空洞挂起,继续 pull 补齐 after_seq=${newContig}`);
|
|
6121
|
-
}
|
|
6122
|
-
}
|
|
6123
|
-
catch (exc) {
|
|
6124
|
-
this._clientLog.debug(`_onV2PushNotification: push payload 解密失败, fallback to pull: ${exc}`);
|
|
6125
|
-
}
|
|
6126
|
-
}
|
|
6127
|
-
// ── 不带 payload 或解密失败:触发 pull ──
|
|
6128
|
-
// 关键:push 通知只表示"服务端有 seq=pushSeq 的新消息",
|
|
6129
|
-
// 此时消息内容尚未到达本地,绝不能调用 onMessageSeq() 推进 contiguousSeq
|
|
6130
|
-
// (那会让随后的 pull 用 after_seq=pushSeq,跳过这条消息本身导致拉空)。
|
|
6131
|
-
// 正确做法:保持 contiguousSeq 不变,用它作为 pull 的 after_seq;
|
|
6132
|
-
// pull 成功 + 解密成功后再由 pull 路径推进 contiguousSeq。
|
|
6133
|
-
if (pushSeq > 0 && ns) {
|
|
6134
|
-
// 纯通知:不更新 contiguousSeq,由 pull 结果驱动推进
|
|
6135
|
-
this._clientLog.debug(`_onV2PushNotification: 纯通知 push_seq=${pushSeq} > contiguous_seq=${contigBefore}, 触发 pull(after_seq=${contigBefore})`);
|
|
6136
|
-
}
|
|
6137
|
-
// only one in flight + drain pending
|
|
6138
|
-
if (this._v2PullInflight) {
|
|
6139
|
-
this._v2PullPending = true;
|
|
6140
|
-
return;
|
|
6141
|
-
}
|
|
6142
|
-
this._v2PullInflight = true;
|
|
6143
|
-
const dedupKey = `p2p_pull:${ns}`;
|
|
6144
|
-
this._gapFillDone.add(dedupKey);
|
|
6145
|
-
try {
|
|
6146
|
-
do {
|
|
6147
|
-
this._v2PullPending = false;
|
|
6148
|
-
await this._pullV2();
|
|
6149
|
-
const newContig = ns ? this._seqTracker.getContiguousSeq(ns) : -1;
|
|
6150
|
-
this._clientLog.debug(`_onV2PushNotification pull done: contiguous_seq=${contigBefore}->${newContig} (push_seq=${pushSeq || 'null'})`);
|
|
6151
|
-
} while (this._v2PullPending);
|
|
6152
|
-
}
|
|
6153
|
-
catch (exc) {
|
|
6154
|
-
const newContig = ns ? this._seqTracker.getContiguousSeq(ns) : -1;
|
|
6155
|
-
this._clientLog.warn(`V2 push auto-pull failed: contiguous_seq=${contigBefore}->${newContig} err=${exc}`);
|
|
6156
|
-
}
|
|
6157
|
-
finally {
|
|
6158
|
-
this._v2PullInflight = false;
|
|
6159
|
-
this._gapFillDone.delete(dedupKey);
|
|
6160
|
-
}
|
|
2802
|
+
return this._delivery.onV2PushNotification(data);
|
|
6161
2803
|
}
|
|
6162
2804
|
/**
|
|
6163
2805
|
* 处理 V2 epoch 轮换事件:清除 bootstrap 缓存 + 触发 SPK rotation。
|
|
6164
2806
|
*/
|
|
6165
2807
|
async _onV2EpochRotated(data) {
|
|
6166
|
-
|
|
6167
|
-
return;
|
|
6168
|
-
const groupId = String(data.group_id ?? '').trim();
|
|
6169
|
-
if (!groupId)
|
|
6170
|
-
return;
|
|
6171
|
-
const newEpoch = data.epoch ?? 0;
|
|
6172
|
-
this._clientLog.debug(`_onV2EpochRotated: group=${groupId} epoch=${newEpoch}`);
|
|
6173
|
-
// 清除 bootstrap 缓存
|
|
6174
|
-
this._v2BootstrapCache.delete(`group:${groupId}`);
|
|
6175
|
-
// 触发 SPK rotation
|
|
6176
|
-
if (this._v2Session) {
|
|
6177
|
-
try {
|
|
6178
|
-
const callFn = async (method, params) => {
|
|
6179
|
-
return this.call(method, params);
|
|
6180
|
-
};
|
|
6181
|
-
await this._v2Session.rotateSPK(callFn);
|
|
6182
|
-
this._clientLog.info(`SPK rotated after epoch change: group=${groupId} epoch=${newEpoch}`);
|
|
6183
|
-
}
|
|
6184
|
-
catch (exc) {
|
|
6185
|
-
this._clientLog.debug(`SPK rotation after epoch change failed (non-fatal): ${exc}`);
|
|
6186
|
-
}
|
|
6187
|
-
}
|
|
2808
|
+
return this._v2E2EE.handleV2EpochRotated(data);
|
|
6188
2809
|
}
|
|
6189
2810
|
/** 安全执行异步操作(不阻塞调用方,错误打 warning 便于排障) */
|
|
6190
2811
|
_safeAsync(promise) {
|
|
@@ -6192,65 +2813,24 @@ export class AUNClient {
|
|
|
6192
2813
|
this._clientLog.warn(`background task exception:${String(exc)}`);
|
|
6193
2814
|
});
|
|
6194
2815
|
}
|
|
2816
|
+
_explicitGroupCursorParams(params) {
|
|
2817
|
+
const value = params._group_cursor_params;
|
|
2818
|
+
if (!isJsonObject(value))
|
|
2819
|
+
return {};
|
|
2820
|
+
return { ...value };
|
|
2821
|
+
}
|
|
2822
|
+
_groupCursorTargetsCurrentInstance(params) {
|
|
2823
|
+
const deviceId = String(params.device_id ?? '').trim();
|
|
2824
|
+
const slotId = String(params.slot_id ?? '').trim();
|
|
2825
|
+
return (!deviceId || deviceId === (this._deviceId ?? ''))
|
|
2826
|
+
&& (!slotId || slotId === (this._slotId ?? ''));
|
|
2827
|
+
}
|
|
6195
2828
|
// ── Pull Gate(序列化同一 key 的并发 pull)──────────────────
|
|
6196
2829
|
_pullGateKeyForCall(method, params) {
|
|
6197
|
-
|
|
6198
|
-
return this._aid ? `p2p:${this._aid}` : '';
|
|
6199
|
-
}
|
|
6200
|
-
if (method === 'group.pull' || method === 'group.v2.pull') {
|
|
6201
|
-
const gid = String(params.group_id ?? '').trim();
|
|
6202
|
-
return gid ? `group:${gid}` : '';
|
|
6203
|
-
}
|
|
6204
|
-
if (method === 'group.pull_events') {
|
|
6205
|
-
const gid = String(params.group_id ?? '').trim();
|
|
6206
|
-
return gid ? `group_event:${gid}` : '';
|
|
6207
|
-
}
|
|
6208
|
-
return '';
|
|
6209
|
-
}
|
|
6210
|
-
_tryAcquirePullGate(key) {
|
|
6211
|
-
if (!key)
|
|
6212
|
-
return 0;
|
|
6213
|
-
const now = Date.now();
|
|
6214
|
-
const gate = this._pullGates.get(key) ?? { inflight: false, startedAt: 0, token: 0 };
|
|
6215
|
-
if (gate.inflight && now - gate.startedAt <= AUNClient._PULL_GATE_STALE_MS) {
|
|
6216
|
-
return null;
|
|
6217
|
-
}
|
|
6218
|
-
if (gate.inflight) {
|
|
6219
|
-
this._clientLog.warn(`pull in-flight stale reset: key=${key} age=${now - gate.startedAt}ms`);
|
|
6220
|
-
}
|
|
6221
|
-
gate.token += 1;
|
|
6222
|
-
gate.inflight = true;
|
|
6223
|
-
gate.startedAt = now;
|
|
6224
|
-
this._pullGates.set(key, gate);
|
|
6225
|
-
return gate.token;
|
|
6226
|
-
}
|
|
6227
|
-
_releasePullGate(key, token) {
|
|
6228
|
-
if (!key || token == null)
|
|
6229
|
-
return;
|
|
6230
|
-
const gate = this._pullGates.get(key);
|
|
6231
|
-
if (!gate || gate.token !== token)
|
|
6232
|
-
return;
|
|
6233
|
-
gate.inflight = false;
|
|
6234
|
-
gate.startedAt = 0;
|
|
2830
|
+
return this._rpcPipeline.pullGateKeyForCall(method, params);
|
|
6235
2831
|
}
|
|
6236
2832
|
async _runPullSerialized(key, operation) {
|
|
6237
|
-
|
|
6238
|
-
if (token === null) {
|
|
6239
|
-
const deadline = Date.now() + AUNClient._PULL_GATE_STALE_MS + 100;
|
|
6240
|
-
while (token === null && Date.now() <= deadline) {
|
|
6241
|
-
await this._sleep(25);
|
|
6242
|
-
token = this._tryAcquirePullGate(key);
|
|
6243
|
-
}
|
|
6244
|
-
if (token === null) {
|
|
6245
|
-
throw new StateError(`pull already in-flight for ${key}`);
|
|
6246
|
-
}
|
|
6247
|
-
}
|
|
6248
|
-
try {
|
|
6249
|
-
return await operation();
|
|
6250
|
-
}
|
|
6251
|
-
finally {
|
|
6252
|
-
this._releasePullGate(key, token);
|
|
6253
|
-
}
|
|
2833
|
+
return await this._rpcPipeline.runPullSerialized(key, operation);
|
|
6254
2834
|
}
|
|
6255
2835
|
/** 可取消的 sleep */
|
|
6256
2836
|
_sleep(ms) {
|
|
@@ -6259,14 +2839,4 @@ export class AUNClient {
|
|
|
6259
2839
|
});
|
|
6260
2840
|
}
|
|
6261
2841
|
}
|
|
6262
|
-
// ── 内部工具 ────────────────────────────────────────
|
|
6263
|
-
/** 生成 UUID v4 */
|
|
6264
|
-
function _uuidV4() {
|
|
6265
|
-
const bytes = new Uint8Array(16);
|
|
6266
|
-
crypto.getRandomValues(bytes);
|
|
6267
|
-
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
6268
|
-
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
6269
|
-
const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
6270
|
-
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
6271
|
-
}
|
|
6272
2842
|
//# sourceMappingURL=client.js.map
|