@agentunion/fastaun-browser 0.3.2 → 0.3.4
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 +81 -0
- package/_packed_docs/KITE_DOCS_GUIDE.md +55 -0
- package/_packed_docs/agent.md//350/277/234/347/250/213agent.md/347/274/223/345/255/230/344/270/216etag/351/200/217/344/274/240/346/226/271/346/241/210.md +328 -0
- package/_packed_docs/cli/AUN-CLI/350/256/276/350/256/241/346/226/207/346/241/243.md +686 -0
- package/_packed_docs/design//350/267/250/350/257/255/350/250/200/345/256/271/345/231/250E2E/346/265/213/350/257/225/346/226/271/346/241/210.md +665 -0
- package/_packed_docs/protocol//351/231/204/345/275/225N-/345/210/206/345/270/203/345/274/217Trace/345/215/217/350/256/256.md +257 -0
- package/_packed_docs/sdk/01-/345/277/253/351/200/237/345/274/200/345/247/213.md +5 -5
- package/_packed_docs/sdk/02-WebSocket/345/215/217/350/256/256.md +1 -1
- package/_packed_docs/sdk/03-/346/240/270/345/277/203/346/246/202/345/277/265.md +2 -2
- package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +454 -396
- package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +1410 -1244
- package/_packed_docs/sdk/07-/351/224/231/350/257/257/345/244/204/347/220/206.md +19 -1
- package/_packed_docs/sdk/08-/346/234/200/344/275/263/345/256/236/350/267/265.md +20 -5
- package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +6 -4
- package/_packed_docs/sdk/E2EE_V2/346/266/210/346/201/257/351/200/232/344/277/241/346/227/266/345/272/217/345/233/276.md +171 -0
- package/_packed_docs/sdk/INDEX.md +9 -4
- package/_packed_docs/sdk/README.md +3 -3
- package/dist/auth.d.ts +10 -11
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +128 -95
- package/dist/auth.js.map +1 -1
- package/dist/bundle.js +2658 -816
- package/dist/client.d.ts +73 -7
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1586 -494
- package/dist/client.js.map +1 -1
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +45 -31
- package/dist/crypto.js.map +1 -1
- package/dist/discovery.d.ts +4 -0
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +16 -11
- package/dist/discovery.js.map +1 -1
- package/dist/errors.d.ts +4 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +7 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/keystore/index.d.ts +27 -0
- package/dist/keystore/index.d.ts.map +1 -1
- package/dist/keystore/indexeddb.d.ts +16 -1
- package/dist/keystore/indexeddb.d.ts.map +1 -1
- package/dist/keystore/indexeddb.js +168 -7
- package/dist/keystore/indexeddb.js.map +1 -1
- package/dist/logger.d.ts +5 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +8 -2
- package/dist/logger.js.map +1 -1
- package/dist/namespaces/auth.d.ts +4 -3
- package/dist/namespaces/auth.d.ts.map +1 -1
- package/dist/namespaces/auth.js +77 -20
- package/dist/namespaces/auth.js.map +1 -1
- package/dist/secret-store/indexeddb-store.js +1 -1
- package/dist/secret-store/indexeddb-store.js.map +1 -1
- package/dist/seq-tracker.d.ts +5 -3
- package/dist/seq-tracker.d.ts.map +1 -1
- package/dist/seq-tracker.js +30 -3
- package/dist/seq-tracker.js.map +1 -1
- package/dist/transport.d.ts +9 -1
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +176 -64
- package/dist/transport.js.map +1 -1
- package/dist/v2/crypto/canonical.d.ts +1 -1
- package/dist/v2/crypto/canonical.d.ts.map +1 -1
- package/dist/v2/crypto/canonical.js +42 -13
- package/dist/v2/crypto/canonical.js.map +1 -1
- package/dist/v2/crypto/ecdh.d.ts.map +1 -1
- package/dist/v2/crypto/ecdh.js +18 -1
- package/dist/v2/crypto/ecdh.js.map +1 -1
- package/dist/v2/e2ee/decrypt.d.ts.map +1 -1
- package/dist/v2/e2ee/decrypt.js +57 -3
- package/dist/v2/e2ee/decrypt.js.map +1 -1
- package/dist/v2/e2ee/encrypt-group.d.ts.map +1 -1
- package/dist/v2/e2ee/encrypt-group.js +16 -6
- package/dist/v2/e2ee/encrypt-group.js.map +1 -1
- package/dist/v2/e2ee/encrypt-p2p.d.ts.map +1 -1
- package/dist/v2/e2ee/encrypt-p2p.js +40 -11
- package/dist/v2/e2ee/encrypt-p2p.js.map +1 -1
- package/dist/v2/e2ee/metadata-auth.d.ts +1 -0
- package/dist/v2/e2ee/metadata-auth.d.ts.map +1 -1
- package/dist/v2/e2ee/metadata-auth.js +51 -0
- package/dist/v2/e2ee/metadata-auth.js.map +1 -1
- package/dist/v2/e2ee/types.d.ts +2 -2
- package/dist/v2/e2ee/types.d.ts.map +1 -1
- package/dist/v2/session/keystore.d.ts +12 -4
- package/dist/v2/session/keystore.d.ts.map +1 -1
- package/dist/v2/session/keystore.js +177 -35
- package/dist/v2/session/keystore.js.map +1 -1
- package/dist/v2/session/session.d.ts +11 -3
- package/dist/v2/session/session.d.ts.map +1 -1
- package/dist/v2/session/session.js +97 -17
- package/dist/v2/session/session.js.map +1 -1
- package/dist/v2/state/commitment.d.ts.map +1 -1
- package/dist/v2/state/commitment.js +4 -1
- package/dist/v2/state/commitment.js.map +1 -1
- package/package.json +43 -43
- package/dist/e2ee-group.d.ts +0 -276
- package/dist/e2ee-group.d.ts.map +0 -1
- package/dist/e2ee-group.js +0 -1653
- package/dist/e2ee-group.js.map +0 -1
package/dist/client.js
CHANGED
|
@@ -18,6 +18,7 @@ import { CryptoProvider, uint8ToBase64, base64ToUint8, pemToArrayBuffer, p1363To
|
|
|
18
18
|
import { IndexedDBKeyStore } from './keystore/indexeddb.js';
|
|
19
19
|
import { V2Session, V2KeyStore } from './v2/session/index.js';
|
|
20
20
|
import { encryptP2PMessage, encryptGroupMessage, decryptMessage, } from './v2/e2ee/index.js';
|
|
21
|
+
import { ecdsaVerifyRaw } from './v2/crypto/ecdsa.js';
|
|
21
22
|
import { computeStateCommitment } from './v2/state/index.js';
|
|
22
23
|
import { AUNLogger } from './logger.js';
|
|
23
24
|
import { AUNError, AuthError, ConnectionError, E2EEError, PermissionError, StateError, ValidationError, } from './errors.js';
|
|
@@ -44,6 +45,15 @@ function stableStringify(obj) {
|
|
|
44
45
|
}
|
|
45
46
|
return JSON.stringify(obj);
|
|
46
47
|
}
|
|
48
|
+
function getV2DeviceId(dev) {
|
|
49
|
+
if (Object.prototype.hasOwnProperty.call(dev, 'device_id')) {
|
|
50
|
+
return { present: true, value: String(dev.device_id ?? '').trim() };
|
|
51
|
+
}
|
|
52
|
+
if (Object.prototype.hasOwnProperty.call(dev, 'owner_device_id')) {
|
|
53
|
+
return { present: true, value: String(dev.owner_device_id ?? '').trim() };
|
|
54
|
+
}
|
|
55
|
+
return { present: false, value: '' };
|
|
56
|
+
}
|
|
47
57
|
function sortObjectKeys(obj) {
|
|
48
58
|
if (obj === null || obj === undefined || typeof obj !== 'object')
|
|
49
59
|
return obj;
|
|
@@ -118,7 +128,16 @@ const REMOVED_E2EE_METHODS = new Set([
|
|
|
118
128
|
]);
|
|
119
129
|
/** 需要客户端签名的关键方法 */
|
|
120
130
|
const SIGNED_METHODS = new Set([
|
|
121
|
-
'
|
|
131
|
+
'message.send',
|
|
132
|
+
'message.v2.put_peer_pk', 'message.v2.bootstrap',
|
|
133
|
+
'message.v2.group_bootstrap', 'message.v2.pull',
|
|
134
|
+
'message.v2.ack',
|
|
135
|
+
'group.send',
|
|
136
|
+
'group.v2.put_group_pk', 'group.v2.bootstrap',
|
|
137
|
+
'group.v2.send', 'group.v2.pull', 'group.v2.ack',
|
|
138
|
+
'group.v2.propose_state', 'group.v2.confirm_state',
|
|
139
|
+
'group.v2.get_proposal',
|
|
140
|
+
'group.kick', 'group.add_member',
|
|
122
141
|
'group.leave', 'group.remove_member', 'group.update_rules',
|
|
123
142
|
'group.update', 'group.update_announcement',
|
|
124
143
|
'group.update_join_requirements', 'group.set_role',
|
|
@@ -251,6 +270,63 @@ function isGroupServiceAid(value) {
|
|
|
251
270
|
const [name, ...issuerParts] = text.split('.');
|
|
252
271
|
return name === 'group' && issuerParts.join('.').length > 0;
|
|
253
272
|
}
|
|
273
|
+
function normalizeV2WrapPolicy(raw) {
|
|
274
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
275
|
+
return undefined;
|
|
276
|
+
const obj = raw;
|
|
277
|
+
let protocol = String(obj.protocol ?? '').trim().toUpperCase();
|
|
278
|
+
let scope = String(obj.scope ?? '').trim().toLowerCase();
|
|
279
|
+
if (scope !== 'aid' && scope !== 'device') {
|
|
280
|
+
if (obj.per_aid_wrap === true)
|
|
281
|
+
scope = 'aid';
|
|
282
|
+
else if (obj.per_device_wrap === true)
|
|
283
|
+
scope = 'device';
|
|
284
|
+
else
|
|
285
|
+
scope = '';
|
|
286
|
+
}
|
|
287
|
+
if (protocol !== '1DH' && protocol !== '3DH')
|
|
288
|
+
protocol = '';
|
|
289
|
+
if (scope === 'aid')
|
|
290
|
+
protocol = '1DH';
|
|
291
|
+
if (!protocol && !scope)
|
|
292
|
+
return undefined;
|
|
293
|
+
return {
|
|
294
|
+
protocol: protocol ? protocol : undefined,
|
|
295
|
+
scope: scope ? scope : undefined,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
function v2WrapCapabilities() {
|
|
299
|
+
return {
|
|
300
|
+
version: 'v2.1',
|
|
301
|
+
protocols: ['1DH', '3DH'],
|
|
302
|
+
scopes: ['aid', 'device'],
|
|
303
|
+
per_aid_wrap: true,
|
|
304
|
+
per_device_wrap: true,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function applyV2WrapPolicyToTargets(targets, policy) {
|
|
308
|
+
if (!policy)
|
|
309
|
+
return targets;
|
|
310
|
+
const normalized = targets.map((target) => {
|
|
311
|
+
const row = { ...target };
|
|
312
|
+
if (policy.protocol === '1DH') {
|
|
313
|
+
row.keySource = 'aid_master';
|
|
314
|
+
row.spkPkDer = undefined;
|
|
315
|
+
row.spkId = '';
|
|
316
|
+
}
|
|
317
|
+
return row;
|
|
318
|
+
});
|
|
319
|
+
if (policy.scope !== 'aid')
|
|
320
|
+
return normalized;
|
|
321
|
+
const collapsed = new Map();
|
|
322
|
+
for (const target of normalized) {
|
|
323
|
+
const key = `${target.aid}\u0000${target.role}`;
|
|
324
|
+
if (!collapsed.has(key)) {
|
|
325
|
+
collapsed.set(key, { ...target, deviceId: '' });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return Array.from(collapsed.values());
|
|
329
|
+
}
|
|
254
330
|
/** 32 字节左侧零填充(用于 P-256 私钥 scalar 规范化) */
|
|
255
331
|
function _v2LeftPad32(b) {
|
|
256
332
|
if (b.length === 32)
|
|
@@ -269,6 +345,43 @@ function _v2B64ToBytes(s) {
|
|
|
269
345
|
out[i] = bin.charCodeAt(i);
|
|
270
346
|
return out;
|
|
271
347
|
}
|
|
348
|
+
function _v2B64ToBytesStrict(s) {
|
|
349
|
+
const text = String(s ?? '').trim();
|
|
350
|
+
if (!text || text.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(text)) {
|
|
351
|
+
throw new Error('invalid base64');
|
|
352
|
+
}
|
|
353
|
+
return _v2B64ToBytes(text);
|
|
354
|
+
}
|
|
355
|
+
function _v2BytesEqual(a, b) {
|
|
356
|
+
if (a.length !== b.length)
|
|
357
|
+
return false;
|
|
358
|
+
let diff = 0;
|
|
359
|
+
for (let i = 0; i < a.length; i++)
|
|
360
|
+
diff |= a[i] ^ b[i];
|
|
361
|
+
return diff === 0;
|
|
362
|
+
}
|
|
363
|
+
function _v2ConcatBytes(...parts) {
|
|
364
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
365
|
+
const out = new Uint8Array(total);
|
|
366
|
+
let offset = 0;
|
|
367
|
+
for (const part of parts) {
|
|
368
|
+
out.set(part, offset);
|
|
369
|
+
offset += part.length;
|
|
370
|
+
}
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
function _v2LengthPrefixedTextKey(...parts) {
|
|
374
|
+
const enc = new TextEncoder();
|
|
375
|
+
return parts.map((part) => `${enc.encode(part).length}:${part};`).join('');
|
|
376
|
+
}
|
|
377
|
+
function _v2LengthPrefixedBytes(...parts) {
|
|
378
|
+
const enc = new TextEncoder();
|
|
379
|
+
const framed = [];
|
|
380
|
+
for (const part of parts) {
|
|
381
|
+
framed.push(enc.encode(`${part.length}:`), part, enc.encode(';'));
|
|
382
|
+
}
|
|
383
|
+
return _v2ConcatBytes(...framed);
|
|
384
|
+
}
|
|
272
385
|
/** Base64URL → Uint8Array(兼容缺失 padding) */
|
|
273
386
|
function _v2B64uToBytes(s) {
|
|
274
387
|
const std = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
@@ -281,12 +394,74 @@ function formatCaughtError(error) {
|
|
|
281
394
|
function v2E2eeMeta(envelope) {
|
|
282
395
|
const suite = String(envelope.suite ?? '');
|
|
283
396
|
const modeSuite = String(envelope.suite ?? 'unknown');
|
|
284
|
-
|
|
397
|
+
const meta = {
|
|
285
398
|
version: 'v2',
|
|
286
399
|
suite,
|
|
287
400
|
encryption_mode: `v2_${modeSuite}`,
|
|
288
401
|
forward_secrecy: true,
|
|
289
402
|
};
|
|
403
|
+
const protectedHeaders = metadataWithoutAuth(envelope.protected_headers);
|
|
404
|
+
if (protectedHeaders && Object.keys(protectedHeaders).length > 0) {
|
|
405
|
+
meta.protected_headers = protectedHeaders;
|
|
406
|
+
}
|
|
407
|
+
const payloadType = String(envelope.payload_type ?? protectedHeaders?.payload_type ?? '').trim();
|
|
408
|
+
if (payloadType) {
|
|
409
|
+
meta.payload_type = payloadType;
|
|
410
|
+
}
|
|
411
|
+
const context = metadataWithoutAuth(envelope.context);
|
|
412
|
+
if (context && Object.keys(context).length > 0) {
|
|
413
|
+
meta.context = context;
|
|
414
|
+
}
|
|
415
|
+
const agentMd = metadataWithoutAuth(envelope.agent_md);
|
|
416
|
+
if (agentMd && Object.keys(agentMd).length > 0) {
|
|
417
|
+
meta.agent_md = agentMd;
|
|
418
|
+
}
|
|
419
|
+
return meta;
|
|
420
|
+
}
|
|
421
|
+
function attachV2EnvelopeMetadata(message, meta) {
|
|
422
|
+
if (!meta)
|
|
423
|
+
return;
|
|
424
|
+
const payloadType = typeof meta.payload_type === 'string' ? meta.payload_type.trim() : '';
|
|
425
|
+
if (payloadType)
|
|
426
|
+
message.payload_type = payloadType;
|
|
427
|
+
if (isJsonObject(meta.protected_headers)) {
|
|
428
|
+
message.protected_headers = { ...meta.protected_headers };
|
|
429
|
+
}
|
|
430
|
+
if (isJsonObject(meta.agent_md)) {
|
|
431
|
+
message.agent_md = { ...meta.agent_md };
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function attachV2EnvelopeMetadataFromSource(message, source) {
|
|
435
|
+
const envelope = extractV2EnvelopeFromSource(source);
|
|
436
|
+
if (envelope)
|
|
437
|
+
attachV2EnvelopeMetadata(message, v2E2eeMeta(envelope));
|
|
438
|
+
}
|
|
439
|
+
function extractV2EnvelopeFromSource(source) {
|
|
440
|
+
if (!isJsonObject(source))
|
|
441
|
+
return null;
|
|
442
|
+
if (isJsonObject(source.payload))
|
|
443
|
+
return source.payload;
|
|
444
|
+
if (typeof source.envelope_json === 'string' && source.envelope_json) {
|
|
445
|
+
try {
|
|
446
|
+
const parsed = JSON.parse(source.envelope_json);
|
|
447
|
+
if (isJsonObject(parsed))
|
|
448
|
+
return parsed;
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
function metadataWithoutAuth(value) {
|
|
457
|
+
if (!isJsonObject(value))
|
|
458
|
+
return null;
|
|
459
|
+
const body = {};
|
|
460
|
+
for (const [key, item] of Object.entries(value)) {
|
|
461
|
+
if (key !== '_auth')
|
|
462
|
+
body[key] = item;
|
|
463
|
+
}
|
|
464
|
+
return body;
|
|
290
465
|
}
|
|
291
466
|
function normalizeDeliveryModeConfig(raw, opts = {}) {
|
|
292
467
|
const defaultMode = String(opts.defaultMode ?? 'fanout').trim().toLowerCase() || 'fanout';
|
|
@@ -379,11 +554,13 @@ export class AUNClient {
|
|
|
379
554
|
_v2Session;
|
|
380
555
|
_v2KeyStore;
|
|
381
556
|
_v2BootstrapCache = new Map();
|
|
557
|
+
_v2SenderIKPending = new Map();
|
|
558
|
+
_v2SenderIKFetching = new Set();
|
|
382
559
|
static V2_BOOTSTRAP_TTL_MS = 60 * 60 * 1000;
|
|
383
560
|
static V2_RETRYABLE_CODES = new Set([-33011, -33012, -33050, -33052, -33054]);
|
|
384
561
|
/** V2 state 签名验证缓存:cacheKey(hex) → expiry_unix_ms */
|
|
385
562
|
_v2SigCache = new Map();
|
|
386
|
-
static _V2_SIG_CACHE_TTL =
|
|
563
|
+
static _V2_SIG_CACHE_TTL = 60 * 60 * 1000;
|
|
387
564
|
static _V2_SIG_CACHE_MAX = 16384;
|
|
388
565
|
/** V2 state chain 本地记录:group_id → [state_version, chain_hash] */
|
|
389
566
|
_v2StateChains = new Map();
|
|
@@ -405,6 +582,11 @@ export class AUNClient {
|
|
|
405
582
|
_localAgentMdEtag = '';
|
|
406
583
|
/** gateway 在 RPC envelope._meta.agent_md_etag 注入的服务端 etag;纯观察,无下游依赖。 */
|
|
407
584
|
_remoteAgentMdEtag = '';
|
|
585
|
+
/** 浏览器侧 AgentMDs 逻辑根目录,正文映射到 IndexedDB 里的 {aid}/agent.md。 */
|
|
586
|
+
_agentMdPath = '';
|
|
587
|
+
_agentMdCache = new Map();
|
|
588
|
+
_agentMdFetchInflight = new Set();
|
|
589
|
+
_agentMdLock = Promise.resolve();
|
|
408
590
|
/** 消息序列号跟踪器(群消息 + P2P 空洞检测) */
|
|
409
591
|
_seqTracker = new SeqTracker();
|
|
410
592
|
_seqTrackerContext = null;
|
|
@@ -418,6 +600,9 @@ export class AUNClient {
|
|
|
418
600
|
_groupSynced = new Set();
|
|
419
601
|
/** gap fill 来源标记:true 表示当前正在补洞(pull 触发),false 表示非补洞 */
|
|
420
602
|
_gapFillActive = false;
|
|
603
|
+
// Pull Gate:序列化同一 key 的并发 pull 操作,防止重复拉取
|
|
604
|
+
_pullGates = new Map();
|
|
605
|
+
static _PULL_GATE_STALE_MS = 30000;
|
|
421
606
|
// 重连相关
|
|
422
607
|
_reconnectActive = false;
|
|
423
608
|
_reconnectAbort = null;
|
|
@@ -444,8 +629,11 @@ export class AUNClient {
|
|
|
444
629
|
root_ca_path: this.configModel.rootCaPem,
|
|
445
630
|
seed_password: this.configModel.seedPassword,
|
|
446
631
|
};
|
|
632
|
+
this._agentMdPath = this._agentMdDefaultRoot();
|
|
633
|
+
this._deviceId = getDeviceId();
|
|
447
634
|
// Logger 必须最早初始化(其他子模块构造时通过 logger 输出)
|
|
448
|
-
this._logger = new AUNLogger({ debug: _debug });
|
|
635
|
+
this._logger = new AUNLogger({ debug: _debug, aunPath: this.configModel.aunPath });
|
|
636
|
+
this._logger.bindDeviceId(this._deviceId);
|
|
449
637
|
this._clientLog = this._logger.for('aun_core.client');
|
|
450
638
|
this._logAuth = this._logger.for('aun_core.auth');
|
|
451
639
|
this._logTransport = this._logger.for('aun_core.transport');
|
|
@@ -455,8 +643,7 @@ export class AUNClient {
|
|
|
455
643
|
this._clientLog.info(`AUNClient initialized: debug=${_debug} aunPath=${this.configModel.aunPath} aid=${initAid ?? '-'}`);
|
|
456
644
|
this._dispatcher = new EventDispatcher();
|
|
457
645
|
this._discovery = new GatewayDiscovery();
|
|
458
|
-
this._keystore = new IndexedDBKeyStore();
|
|
459
|
-
this._deviceId = getDeviceId();
|
|
646
|
+
this._keystore = new IndexedDBKeyStore({ encryptionSeed: this.configModel.seedPassword ?? undefined });
|
|
460
647
|
this._slotId = '';
|
|
461
648
|
this._connectDeliveryMode = normalizeDeliveryModeConfig({ mode: 'fanout' });
|
|
462
649
|
this._defaultConnectDeliveryMode = { ...this._connectDeliveryMode };
|
|
@@ -475,7 +662,11 @@ export class AUNClient {
|
|
|
475
662
|
timeout: DEFAULT_SESSION_OPTIONS.timeouts.call,
|
|
476
663
|
onDisconnect: (error, closeCode) => this._handleTransportDisconnect(error, closeCode),
|
|
477
664
|
});
|
|
478
|
-
this._transport.setMetaObserver((meta) =>
|
|
665
|
+
this._transport.setMetaObserver((meta) => {
|
|
666
|
+
void this._observeRpcMeta(meta).catch((exc) => {
|
|
667
|
+
this._clientLog.debug(`agent.md meta observer skipped: ${String(exc)}`);
|
|
668
|
+
});
|
|
669
|
+
});
|
|
479
670
|
this.auth = new AuthNamespace(this);
|
|
480
671
|
this.custody = new CustodyNamespace(this);
|
|
481
672
|
this.meta = new MetaNamespace(this);
|
|
@@ -548,30 +739,64 @@ export class AUNClient {
|
|
|
548
739
|
get aid() {
|
|
549
740
|
return this._aid;
|
|
550
741
|
}
|
|
742
|
+
setAgentMdPath(root) {
|
|
743
|
+
const next = String(root ?? '').trim() || this._agentMdDefaultRoot();
|
|
744
|
+
this._agentMdPath = next;
|
|
745
|
+
this._agentMdCache.clear();
|
|
746
|
+
return next;
|
|
747
|
+
}
|
|
748
|
+
setAgentMDPath(root) {
|
|
749
|
+
return this.setAgentMdPath(root);
|
|
750
|
+
}
|
|
751
|
+
SetAgentMDPath(root) {
|
|
752
|
+
return this.setAgentMdPath(root);
|
|
753
|
+
}
|
|
551
754
|
/**
|
|
552
|
-
* 浏览器版本 publishAgentMd
|
|
553
|
-
*
|
|
755
|
+
* 浏览器版本 publishAgentMd。默认从 {agentMdPath}/{self_aid}/agent.md 的等价 IndexedDB 正文读取,
|
|
756
|
+
* 然后签名、上传,并刷新 agentmd.json 元数据。
|
|
554
757
|
*
|
|
555
|
-
*
|
|
758
|
+
* 兼容旧浏览器调用:传入 content 时会先写入等价正文,再从该正文发布。
|
|
556
759
|
*/
|
|
557
760
|
async publishAgentMd(content) {
|
|
558
|
-
const
|
|
559
|
-
if (
|
|
560
|
-
throw new ValidationError('publishAgentMd requires
|
|
761
|
+
const target = this._agentMdOwnerAid();
|
|
762
|
+
if (!target) {
|
|
763
|
+
throw new ValidationError('publishAgentMd requires local AID');
|
|
764
|
+
}
|
|
765
|
+
if (content !== undefined && content !== null) {
|
|
766
|
+
const text = String(content ?? '');
|
|
767
|
+
if (text.length === 0) {
|
|
768
|
+
throw new ValidationError('publishAgentMd requires non-empty content');
|
|
769
|
+
}
|
|
770
|
+
await this._saveAgentMdRecord(target, {
|
|
771
|
+
content: text,
|
|
772
|
+
local_etag: await this._agentMdContentEtag(text),
|
|
773
|
+
fetched_at: Date.now(),
|
|
774
|
+
});
|
|
561
775
|
}
|
|
562
|
-
const
|
|
776
|
+
const localContent = await this._readAgentMdContent(target);
|
|
777
|
+
if (localContent === null || localContent.length === 0) {
|
|
778
|
+
throw new ValidationError('publishAgentMd requires local agent.md content');
|
|
779
|
+
}
|
|
780
|
+
const signed = await this.auth.signAgentMd(localContent);
|
|
563
781
|
const result = await this.auth.uploadAgentMd(signed);
|
|
564
|
-
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
.
|
|
568
|
-
|
|
569
|
-
|
|
782
|
+
this._localAgentMdEtag = await this._agentMdContentEtag(signed);
|
|
783
|
+
const remoteEtag = isJsonObject(result) ? String(result.etag ?? '').trim() : '';
|
|
784
|
+
if (remoteEtag)
|
|
785
|
+
this._remoteAgentMdEtag = remoteEtag;
|
|
786
|
+
await this._saveAgentMdRecord(target, {
|
|
787
|
+
content: signed,
|
|
788
|
+
local_etag: this._localAgentMdEtag,
|
|
789
|
+
remote_etag: remoteEtag || undefined,
|
|
790
|
+
last_modified: isJsonObject(result) ? String(result.last_modified ?? '').trim() : '',
|
|
791
|
+
fetched_at: Date.now(),
|
|
792
|
+
remote_status: remoteEtag ? 'found' : 'unknown',
|
|
793
|
+
last_error: '',
|
|
794
|
+
});
|
|
570
795
|
return result;
|
|
571
796
|
}
|
|
572
797
|
/**
|
|
573
|
-
* 浏览器版本 fetchAgentMd。aid
|
|
574
|
-
*
|
|
798
|
+
* 浏览器版本 fetchAgentMd。aid 缺省时取自身;下载后的正文固定写入
|
|
799
|
+
* {agentMdPath}/{aid}/agent.md 的等价 IndexedDB 正文,agentmd.json 只保存元数据。
|
|
575
800
|
*/
|
|
576
801
|
async fetchAgentMd(aid) {
|
|
577
802
|
const target = String(aid ?? this._aid ?? '').trim();
|
|
@@ -581,17 +806,30 @@ export class AUNClient {
|
|
|
581
806
|
const content = await this.auth.downloadAgentMd(target);
|
|
582
807
|
const signature = await this.auth.verifyAgentMd(content, { aid: target });
|
|
583
808
|
const isSelf = target === (this._aid ?? '');
|
|
809
|
+
const localEtag = await this._agentMdContentEtag(content);
|
|
810
|
+
const cacheMeta = this._agentMdAuthCacheMeta(target);
|
|
811
|
+
const remoteEtag = String(cacheMeta.etag ?? '').trim();
|
|
812
|
+
const lastModified = String(cacheMeta.lastModified ?? cacheMeta.last_modified ?? '').trim();
|
|
813
|
+
if (isSelf) {
|
|
814
|
+
this._localAgentMdEtag = localEtag;
|
|
815
|
+
if (remoteEtag)
|
|
816
|
+
this._remoteAgentMdEtag = remoteEtag;
|
|
817
|
+
}
|
|
818
|
+
await this._saveAgentMdRecord(target, {
|
|
819
|
+
content,
|
|
820
|
+
local_etag: localEtag,
|
|
821
|
+
remote_etag: remoteEtag || undefined,
|
|
822
|
+
last_modified: lastModified || undefined,
|
|
823
|
+
fetched_at: Date.now(),
|
|
824
|
+
remote_status: 'found',
|
|
825
|
+
verify_status: isJsonObject(signature) ? String(signature.status ?? '') : '',
|
|
826
|
+
verify_error: isJsonObject(signature) ? String(signature.reason ?? '') : '',
|
|
827
|
+
last_error: '',
|
|
828
|
+
});
|
|
584
829
|
let in_sync = null;
|
|
585
830
|
if (isSelf) {
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
const hex = Array.from(new Uint8Array(digest))
|
|
589
|
-
.map((b) => b.toString(16).padStart(2, '0'))
|
|
590
|
-
.join('');
|
|
591
|
-
this._localAgentMdEtag = `"${hex}"`;
|
|
592
|
-
const local = this._localAgentMdEtag || '';
|
|
593
|
-
const remote = this._remoteAgentMdEtag || '';
|
|
594
|
-
in_sync = local && remote ? local === remote : false;
|
|
831
|
+
const remote = remoteEtag || this._remoteAgentMdEtag || '';
|
|
832
|
+
in_sync = localEtag && remote ? localEtag === remote : false;
|
|
595
833
|
}
|
|
596
834
|
return {
|
|
597
835
|
aid: target,
|
|
@@ -600,13 +838,396 @@ export class AUNClient {
|
|
|
600
838
|
in_sync,
|
|
601
839
|
};
|
|
602
840
|
}
|
|
841
|
+
getLocalAgentMdEtag() {
|
|
842
|
+
return this._localAgentMdEtag;
|
|
843
|
+
}
|
|
844
|
+
getRemoteAgentMdEtag() {
|
|
845
|
+
return this._remoteAgentMdEtag;
|
|
846
|
+
}
|
|
847
|
+
async _agentMdContentEtag(content) {
|
|
848
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(String(content ?? '')));
|
|
849
|
+
const hex = Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
850
|
+
return `"${hex}"`;
|
|
851
|
+
}
|
|
852
|
+
_agentMdOwnerAid() {
|
|
853
|
+
return String(this._aid ?? '').trim();
|
|
854
|
+
}
|
|
855
|
+
_agentMdDefaultRoot() {
|
|
856
|
+
return this._joinAgentMdPath(this.configModel.aunPath || '.', 'AgentMDs');
|
|
857
|
+
}
|
|
858
|
+
_joinAgentMdPath(base, name) {
|
|
859
|
+
const left = String(base ?? '').trim().replace(/[\\/]+$/g, '');
|
|
860
|
+
return left ? `${left}/${name}` : name;
|
|
861
|
+
}
|
|
862
|
+
_agentMdRoot() {
|
|
863
|
+
return this._agentMdPath || this._agentMdDefaultRoot();
|
|
864
|
+
}
|
|
865
|
+
_agentMdSafeAid(aid) {
|
|
866
|
+
const target = String(aid ?? '').trim();
|
|
867
|
+
if (!target || target.includes('/') || target.includes('\\') || target.includes('\0')) {
|
|
868
|
+
throw new ValidationError('agent.md aid is empty or contains path separators');
|
|
869
|
+
}
|
|
870
|
+
return target;
|
|
871
|
+
}
|
|
872
|
+
_agentMdMetaKey(aid) {
|
|
873
|
+
return `${this._agentMdSafeAid(aid)}/agentmd.json`;
|
|
874
|
+
}
|
|
875
|
+
_agentMdContentKey(aid) {
|
|
876
|
+
return `${this._agentMdSafeAid(aid)}/agent.md`;
|
|
877
|
+
}
|
|
878
|
+
async _readAgentMdStorage(logicalKey) {
|
|
879
|
+
const key = String(logicalKey ?? '').trim();
|
|
880
|
+
if (!key)
|
|
881
|
+
return null;
|
|
882
|
+
const load = this._keystore.loadAgentMdCache;
|
|
883
|
+
if (typeof load !== 'function') {
|
|
884
|
+
throw new Error('IndexedDB agent.md storage unavailable');
|
|
885
|
+
}
|
|
886
|
+
const record = await load.call(this._keystore, this._agentMdRoot(), key);
|
|
887
|
+
if (record && Object.prototype.hasOwnProperty.call(record, 'content')) {
|
|
888
|
+
return String(record.content ?? '');
|
|
889
|
+
}
|
|
890
|
+
return null;
|
|
891
|
+
}
|
|
892
|
+
async _writeAgentMdStorage(logicalKey, content) {
|
|
893
|
+
const key = String(logicalKey ?? '').trim();
|
|
894
|
+
if (!key)
|
|
895
|
+
return;
|
|
896
|
+
const save = this._keystore.upsertAgentMdCache;
|
|
897
|
+
if (typeof save !== 'function') {
|
|
898
|
+
throw new Error('IndexedDB agent.md storage unavailable');
|
|
899
|
+
}
|
|
900
|
+
const text = String(content ?? '');
|
|
901
|
+
await save.call(this._keystore, this._agentMdRoot(), key, {
|
|
902
|
+
content: text,
|
|
903
|
+
local_etag: await this._agentMdContentEtag(text),
|
|
904
|
+
fetched_at: Date.now(),
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
async _withAgentMdLock(fn) {
|
|
908
|
+
const previous = this._agentMdLock.catch(() => undefined);
|
|
909
|
+
let release;
|
|
910
|
+
const current = new Promise((resolve) => { release = resolve; });
|
|
911
|
+
this._agentMdLock = previous.then(() => current);
|
|
912
|
+
await previous;
|
|
913
|
+
try {
|
|
914
|
+
return await fn();
|
|
915
|
+
}
|
|
916
|
+
finally {
|
|
917
|
+
release();
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
_normalizeAgentMdRecord(aid, data) {
|
|
921
|
+
if (!isJsonObject(data))
|
|
922
|
+
return {};
|
|
923
|
+
const record = {};
|
|
924
|
+
for (const [key, value] of Object.entries(data)) {
|
|
925
|
+
if (key !== 'content')
|
|
926
|
+
record[key] = value;
|
|
927
|
+
}
|
|
928
|
+
record.aid = this._agentMdSafeAid(String(record.aid ?? aid));
|
|
929
|
+
for (const key of ['fetched_at', 'observed_at', 'checked_at', 'updated_at']) {
|
|
930
|
+
record[key] = Number(record[key] ?? 0) || 0;
|
|
931
|
+
}
|
|
932
|
+
return record;
|
|
933
|
+
}
|
|
934
|
+
async _writeAgentMdRecordUnlocked(aid, record) {
|
|
935
|
+
const payload = {};
|
|
936
|
+
for (const [key, value] of Object.entries(record)) {
|
|
937
|
+
if (key !== 'content' && value !== undefined && value !== null)
|
|
938
|
+
payload[key] = value;
|
|
939
|
+
}
|
|
940
|
+
payload.aid = this._agentMdSafeAid(aid);
|
|
941
|
+
await this._writeAgentMdStorage(this._agentMdMetaKey(aid), `${JSON.stringify(payload, null, 2)}\n`);
|
|
942
|
+
}
|
|
943
|
+
async _readAgentMdRecordUnlocked(aid) {
|
|
944
|
+
const raw = await this._readAgentMdStorage(this._agentMdMetaKey(aid));
|
|
945
|
+
if (raw === null)
|
|
946
|
+
return {};
|
|
947
|
+
try {
|
|
948
|
+
return this._normalizeAgentMdRecord(aid, JSON.parse(raw));
|
|
949
|
+
}
|
|
950
|
+
catch (err) {
|
|
951
|
+
this._clientLog.warn(`agent.md metadata damaged, ignoring: aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
952
|
+
return {};
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
async _readAgentMdContent(aid) {
|
|
956
|
+
return await this._readAgentMdStorage(this._agentMdContentKey(aid));
|
|
957
|
+
}
|
|
958
|
+
async _writeAgentMdContent(aid, content) {
|
|
959
|
+
await this._writeAgentMdStorage(this._agentMdContentKey(aid), String(content ?? ''));
|
|
960
|
+
}
|
|
961
|
+
_agentMdAuthCacheMeta(aid) {
|
|
962
|
+
try {
|
|
963
|
+
const store = this.auth._agentMdCache;
|
|
964
|
+
const record = store?.get(String(aid ?? '').trim());
|
|
965
|
+
return record && typeof record === 'object' ? { ...record } : {};
|
|
966
|
+
}
|
|
967
|
+
catch {
|
|
968
|
+
return {};
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
async _loadAgentMdRecord(aid) {
|
|
972
|
+
const target = String(aid ?? '').trim();
|
|
973
|
+
if (!target)
|
|
974
|
+
return null;
|
|
975
|
+
try {
|
|
976
|
+
const loaded = await this._withAgentMdLock(async () => {
|
|
977
|
+
const record = await this._readAgentMdRecordUnlocked(target);
|
|
978
|
+
const next = Object.keys(record).length > 0 ? { ...record, aid: target } : { aid: target };
|
|
979
|
+
try {
|
|
980
|
+
const content = await this._readAgentMdContent(target);
|
|
981
|
+
if (content !== null) {
|
|
982
|
+
next.content = content;
|
|
983
|
+
next.local_etag = await this._agentMdContentEtag(content);
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
// 元数据存在但正文缺失
|
|
987
|
+
const metaRaw = await this._readAgentMdStorage(this._agentMdMetaKey(target));
|
|
988
|
+
if (metaRaw !== null) {
|
|
989
|
+
this._clientLog.warn(`agent.md content read failed: aid=${target}`);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
catch (err) {
|
|
994
|
+
this._clientLog.warn(`agent.md content read failed: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
|
|
995
|
+
}
|
|
996
|
+
return next;
|
|
997
|
+
});
|
|
998
|
+
if (Object.keys(loaded).length <= 1)
|
|
999
|
+
return null;
|
|
1000
|
+
this._agentMdCache.set(target, { ...loaded });
|
|
1001
|
+
return { ...loaded };
|
|
1002
|
+
}
|
|
1003
|
+
catch (err) {
|
|
1004
|
+
this._clientLog.debug(`agent.md cache load skipped: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
|
|
1005
|
+
}
|
|
1006
|
+
return null;
|
|
1007
|
+
}
|
|
1008
|
+
async _saveAgentMdRecord(aid, fields) {
|
|
1009
|
+
const target = String(aid ?? '').trim();
|
|
1010
|
+
if (!target)
|
|
1011
|
+
return {};
|
|
1012
|
+
try {
|
|
1013
|
+
const inputFields = { ...fields };
|
|
1014
|
+
const hasContent = Object.prototype.hasOwnProperty.call(inputFields, 'content') && inputFields.content !== undefined && inputFields.content !== null;
|
|
1015
|
+
if (hasContent) {
|
|
1016
|
+
const text = String(inputFields.content ?? '');
|
|
1017
|
+
await this._writeAgentMdContent(target, text);
|
|
1018
|
+
if (!inputFields.local_etag)
|
|
1019
|
+
inputFields.local_etag = await this._agentMdContentEtag(text);
|
|
1020
|
+
if (!inputFields.fetched_at)
|
|
1021
|
+
inputFields.fetched_at = Date.now();
|
|
1022
|
+
}
|
|
1023
|
+
delete inputFields.content;
|
|
1024
|
+
const record = await this._withAgentMdLock(async () => {
|
|
1025
|
+
const next = { ...(await this._readAgentMdRecordUnlocked(target)), aid: target };
|
|
1026
|
+
for (const [key, value] of Object.entries(inputFields)) {
|
|
1027
|
+
if (value !== undefined && value !== null)
|
|
1028
|
+
next[key] = value;
|
|
1029
|
+
}
|
|
1030
|
+
next.updated_at = Date.now();
|
|
1031
|
+
await this._writeAgentMdRecordUnlocked(target, next);
|
|
1032
|
+
return next;
|
|
1033
|
+
});
|
|
1034
|
+
const loaded = { ...record };
|
|
1035
|
+
if (hasContent)
|
|
1036
|
+
loaded.content = String(fields.content ?? '');
|
|
1037
|
+
this._agentMdCache.set(target, { ...loaded });
|
|
1038
|
+
const owner = this._agentMdOwnerAid();
|
|
1039
|
+
if (target === owner) {
|
|
1040
|
+
const localEtag = String(loaded.local_etag ?? '').trim();
|
|
1041
|
+
const remoteEtag = String(loaded.remote_etag ?? '').trim();
|
|
1042
|
+
if (localEtag)
|
|
1043
|
+
this._localAgentMdEtag = localEtag;
|
|
1044
|
+
if (remoteEtag)
|
|
1045
|
+
this._remoteAgentMdEtag = remoteEtag;
|
|
1046
|
+
}
|
|
1047
|
+
return { ...loaded };
|
|
1048
|
+
}
|
|
1049
|
+
catch (err) {
|
|
1050
|
+
this._clientLog.debug(`agent.md cache save skipped: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
|
|
1051
|
+
}
|
|
1052
|
+
return {};
|
|
1053
|
+
}
|
|
1054
|
+
async _agentMdHasLocalContent(aid, record) {
|
|
1055
|
+
if (record && typeof record.content === 'string' && record.content.length > 0)
|
|
1056
|
+
return true;
|
|
1057
|
+
try {
|
|
1058
|
+
return (await this._readAgentMdContent(aid)) !== null;
|
|
1059
|
+
}
|
|
1060
|
+
catch {
|
|
1061
|
+
return false;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
_agentMdCheckedAtFresh(checkedAtMs, maxUnsyncedDays) {
|
|
1065
|
+
const days = Number(maxUnsyncedDays || 0);
|
|
1066
|
+
if (!Number.isFinite(days) || days <= 0)
|
|
1067
|
+
return false;
|
|
1068
|
+
if (!Number.isFinite(checkedAtMs) || checkedAtMs <= 0)
|
|
1069
|
+
return false;
|
|
1070
|
+
return Date.now() - checkedAtMs <= days * 86400000;
|
|
1071
|
+
}
|
|
1072
|
+
_agentMdLastModifiedFresh(lastModified, maxUnsyncedDays) {
|
|
1073
|
+
const days = Number(maxUnsyncedDays || 0);
|
|
1074
|
+
if (!Number.isFinite(days) || days <= 0)
|
|
1075
|
+
return false;
|
|
1076
|
+
const ts = Date.parse(String(lastModified ?? '').trim());
|
|
1077
|
+
if (!Number.isFinite(ts))
|
|
1078
|
+
return false;
|
|
1079
|
+
return Date.now() <= ts + days * 86400000;
|
|
1080
|
+
}
|
|
1081
|
+
async _scheduleAgentMdFetchIfMissing(aid, record, source = '') {
|
|
1082
|
+
const target = String(aid ?? '').trim();
|
|
1083
|
+
if (!target || await this._agentMdHasLocalContent(target, record))
|
|
1084
|
+
return;
|
|
1085
|
+
if (this._agentMdFetchInflight.has(target))
|
|
1086
|
+
return;
|
|
1087
|
+
this._agentMdFetchInflight.add(target);
|
|
1088
|
+
try {
|
|
1089
|
+
await this.fetchAgentMd(target);
|
|
1090
|
+
}
|
|
1091
|
+
catch (err) {
|
|
1092
|
+
await this._saveAgentMdRecord(target, {
|
|
1093
|
+
last_error: err instanceof Error ? err.message : String(err),
|
|
1094
|
+
remote_status: 'found',
|
|
1095
|
+
});
|
|
1096
|
+
this._clientLog.debug(`agent.md auto fetch failed: aid=${target} source=${source || '-'} err=${err instanceof Error ? err.message : String(err)}`);
|
|
1097
|
+
}
|
|
1098
|
+
finally {
|
|
1099
|
+
this._agentMdFetchInflight.delete(target);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
async _observeAgentMdMeta(aid, etag = '', lastModified = '', source = '') {
|
|
1103
|
+
const target = String(aid ?? '').trim();
|
|
1104
|
+
const remoteEtag = String(etag ?? '').trim();
|
|
1105
|
+
const remoteLastModified = String(lastModified ?? '').trim();
|
|
1106
|
+
if (!target || (!remoteEtag && !remoteLastModified))
|
|
1107
|
+
return;
|
|
1108
|
+
let before = this._agentMdCache.get(target);
|
|
1109
|
+
if (!before || typeof before !== 'object')
|
|
1110
|
+
before = await this._loadAgentMdRecord(target) ?? {};
|
|
1111
|
+
const same = (!remoteEtag || String(before.remote_etag ?? '').trim() === remoteEtag) &&
|
|
1112
|
+
(!remoteLastModified || String(before.last_modified ?? '').trim() === remoteLastModified);
|
|
1113
|
+
let record = { ...before };
|
|
1114
|
+
if (!same || Object.keys(before).length === 0) {
|
|
1115
|
+
const fields = {
|
|
1116
|
+
observed_at: Date.now(),
|
|
1117
|
+
remote_status: 'found',
|
|
1118
|
+
};
|
|
1119
|
+
if (remoteEtag)
|
|
1120
|
+
fields.remote_etag = remoteEtag;
|
|
1121
|
+
if (remoteLastModified)
|
|
1122
|
+
fields.last_modified = remoteLastModified;
|
|
1123
|
+
record = await this._saveAgentMdRecord(target, fields) || record;
|
|
1124
|
+
}
|
|
1125
|
+
if (target === this._agentMdOwnerAid() && remoteEtag)
|
|
1126
|
+
this._remoteAgentMdEtag = remoteEtag;
|
|
1127
|
+
await this._scheduleAgentMdFetchIfMissing(target, record, source);
|
|
1128
|
+
this._clientLog.debug(`agent.md meta observed: aid=${target} etag=${remoteEtag || '-'} last_modified=${remoteLastModified || '-'} source=${source || '-'}`);
|
|
1129
|
+
}
|
|
1130
|
+
async _observeAgentMdEtag(aid, etag, source = '') {
|
|
1131
|
+
await this._observeAgentMdMeta(aid, etag, '', source);
|
|
1132
|
+
}
|
|
1133
|
+
async _observeAgentMdFromEnvelope(envelope) {
|
|
1134
|
+
if (!isJsonObject(envelope))
|
|
1135
|
+
return;
|
|
1136
|
+
const env = envelope;
|
|
1137
|
+
if (!isJsonObject(env.agent_md))
|
|
1138
|
+
return;
|
|
1139
|
+
const agentMd = env.agent_md;
|
|
1140
|
+
if (!isJsonObject(agentMd.sender))
|
|
1141
|
+
return;
|
|
1142
|
+
const sender = agentMd.sender;
|
|
1143
|
+
let senderAid = String(sender.aid ?? '').trim();
|
|
1144
|
+
if (!senderAid) {
|
|
1145
|
+
const aad = isJsonObject(env.aad) ? env.aad : {};
|
|
1146
|
+
senderAid = String(aad.from ?? env.from ?? '').trim();
|
|
1147
|
+
}
|
|
1148
|
+
await this._observeAgentMdMeta(senderAid, String(sender.etag ?? '').trim(), String(sender.last_modified ?? sender.lastModified ?? '').trim(), 'envelope');
|
|
1149
|
+
}
|
|
1150
|
+
async checkAgentMd(aid, maxUnsyncedDays = 0) {
|
|
1151
|
+
const target = String(aid ?? this._aid ?? '').trim();
|
|
1152
|
+
if (!target)
|
|
1153
|
+
throw new ValidationError('checkAgentMd requires aid (or local AID)');
|
|
1154
|
+
const before = await this._loadAgentMdRecord(target) ?? {};
|
|
1155
|
+
const localEtag = String(before.local_etag ?? '').trim();
|
|
1156
|
+
const localFound = !!(Object.keys(before).length > 0 && (String(before.content ?? '') || localEtag));
|
|
1157
|
+
const remoteEtagCached = String(before.remote_etag ?? '').trim();
|
|
1158
|
+
const lastModifiedCached = String(before.last_modified ?? '').trim();
|
|
1159
|
+
const checkedAtCached = Number(before.checked_at ?? 0);
|
|
1160
|
+
const cachedInSync = !!(localFound && localEtag && remoteEtagCached && localEtag === remoteEtagCached);
|
|
1161
|
+
// max_unsynced_days > 0 且距上次 HEAD 在窗口内 → 直接返回缓存;否则强制 HEAD。
|
|
1162
|
+
if (cachedInSync && this._agentMdCheckedAtFresh(checkedAtCached, maxUnsyncedDays)) {
|
|
1163
|
+
return {
|
|
1164
|
+
aid: target,
|
|
1165
|
+
local_found: true,
|
|
1166
|
+
remote_found: true,
|
|
1167
|
+
local_etag: localEtag,
|
|
1168
|
+
remote_etag: remoteEtagCached,
|
|
1169
|
+
in_sync: true,
|
|
1170
|
+
last_modified: lastModifiedCached,
|
|
1171
|
+
status: 200,
|
|
1172
|
+
cached: true,
|
|
1173
|
+
verify_status: String(before.verify_status ?? ''),
|
|
1174
|
+
verify_error: String(before.verify_error ?? ''),
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
const now = Date.now();
|
|
1178
|
+
let remote;
|
|
1179
|
+
try {
|
|
1180
|
+
remote = await this.auth.headAgentMd(target);
|
|
1181
|
+
}
|
|
1182
|
+
catch (err) {
|
|
1183
|
+
await this._saveAgentMdRecord(target, { checked_at: now, remote_status: 'error', last_error: err instanceof Error ? err.message : String(err) });
|
|
1184
|
+
throw err;
|
|
1185
|
+
}
|
|
1186
|
+
const remoteFound = !!remote.found;
|
|
1187
|
+
const remoteEtag = String(remote.etag ?? '').trim();
|
|
1188
|
+
const lastModified = String(remote.last_modified ?? remote.lastModified ?? '').trim();
|
|
1189
|
+
const saved = await this._saveAgentMdRecord(target, {
|
|
1190
|
+
remote_etag: remoteFound ? remoteEtag : '',
|
|
1191
|
+
last_modified: lastModified,
|
|
1192
|
+
checked_at: now,
|
|
1193
|
+
remote_status: remoteFound ? 'found' : 'missing',
|
|
1194
|
+
last_error: '',
|
|
1195
|
+
});
|
|
1196
|
+
if (target === this._agentMdOwnerAid() && remoteEtag)
|
|
1197
|
+
this._remoteAgentMdEtag = remoteEtag;
|
|
1198
|
+
const inSync = !!(localFound && remoteFound && localEtag && remoteEtag && localEtag === remoteEtag);
|
|
1199
|
+
return {
|
|
1200
|
+
aid: target,
|
|
1201
|
+
local_found: localFound,
|
|
1202
|
+
remote_found: remoteFound,
|
|
1203
|
+
local_etag: localEtag,
|
|
1204
|
+
remote_etag: remoteEtag,
|
|
1205
|
+
in_sync: inSync,
|
|
1206
|
+
last_modified: lastModified,
|
|
1207
|
+
status: Number(remote.status ?? (remoteFound ? 200 : 404)),
|
|
1208
|
+
cached: false,
|
|
1209
|
+
verify_status: String(saved.verify_status ?? before.verify_status ?? ''),
|
|
1210
|
+
verify_error: String(saved.verify_error ?? before.verify_error ?? ''),
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
603
1213
|
/** transport 的 meta observer:吸收 gateway 注入的 _meta 字段。失败不影响业务。 */
|
|
604
|
-
_observeRpcMeta(meta) {
|
|
1214
|
+
async _observeRpcMeta(meta) {
|
|
605
1215
|
if (!isJsonObject(meta))
|
|
606
1216
|
return;
|
|
607
1217
|
const etag = String(meta.agent_md_etag ?? '').trim();
|
|
608
1218
|
if (etag) {
|
|
609
1219
|
this._remoteAgentMdEtag = etag;
|
|
1220
|
+
await this._observeAgentMdMeta(this._aid ?? '', etag, '', 'rpc.self');
|
|
1221
|
+
}
|
|
1222
|
+
const etags = meta.agent_md_etags;
|
|
1223
|
+
if (isJsonObject(etags)) {
|
|
1224
|
+
// role key 优先级:requester / peer 是新规范,其余是兼容旧 SDK 的别名。
|
|
1225
|
+
for (const key of ['requester', 'peer', 'receiver', 'target', 'to', 'sender', 'from']) {
|
|
1226
|
+
const item = etags[key];
|
|
1227
|
+
if (!isJsonObject(item))
|
|
1228
|
+
continue;
|
|
1229
|
+
await this._observeAgentMdMeta(String(item.aid ?? ''), String(item.etag ?? ''), String(item.last_modified ?? item.lastModified ?? ''), `rpc.${key}`);
|
|
1230
|
+
}
|
|
610
1231
|
}
|
|
611
1232
|
}
|
|
612
1233
|
get state() {
|
|
@@ -660,18 +1281,30 @@ export class AUNClient {
|
|
|
660
1281
|
this._sessionOptions = this._buildSessionOptions(normalized);
|
|
661
1282
|
this._transport.setTimeout(this._sessionOptions.timeouts.call);
|
|
662
1283
|
this._closing = false;
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
1284
|
+
const gateways = this._resolveGateways(normalized);
|
|
1285
|
+
let lastErr = null;
|
|
1286
|
+
for (const gw of gateways) {
|
|
1287
|
+
try {
|
|
1288
|
+
const gwParams = { ...normalized, gateway: gw };
|
|
1289
|
+
await this._connectOnce(gwParams, false);
|
|
1290
|
+
this._clientLog.debug(`connect exit: elapsed=${Date.now() - tStart}ms state=${this._state}`);
|
|
1291
|
+
return;
|
|
671
1292
|
}
|
|
672
|
-
|
|
673
|
-
|
|
1293
|
+
catch (err) {
|
|
1294
|
+
lastErr = err;
|
|
1295
|
+
if (gateways.length > 1) {
|
|
1296
|
+
this._clientLog.warn(`connect: gateway ${gw} failed, trying next: ${err instanceof Error ? err.message : String(err)}`);
|
|
1297
|
+
}
|
|
1298
|
+
if (this._state === 'connecting' || this._state === 'authenticating') {
|
|
1299
|
+
this._state = 'connecting';
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
if (this._state === 'connecting' || this._state === 'authenticating') {
|
|
1304
|
+
this._state = 'disconnected';
|
|
674
1305
|
}
|
|
1306
|
+
this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
|
|
1307
|
+
throw lastErr;
|
|
675
1308
|
}
|
|
676
1309
|
/** 断开连接但保留本地状态,可再次 connect */
|
|
677
1310
|
async disconnect() {
|
|
@@ -803,6 +1436,9 @@ export class AUNClient {
|
|
|
803
1436
|
throw new PermissionError(`legacy E2EE method is removed in this SDK: ${method}`);
|
|
804
1437
|
}
|
|
805
1438
|
const p = { ...(params ?? {}) };
|
|
1439
|
+
if (method === 'message.send' || method === 'group.send') {
|
|
1440
|
+
this._normalizeOutboundMessagePayload(p, method);
|
|
1441
|
+
}
|
|
806
1442
|
this._validateOutboundCall(method, p);
|
|
807
1443
|
this._injectMessageCursorContext(method, p);
|
|
808
1444
|
// group.* 方法的 group_id 归一化为 canonical 格式(兼容老/污染数据)
|
|
@@ -815,7 +1451,7 @@ export class AUNClient {
|
|
|
815
1451
|
p.group_id = normalizedGroupId;
|
|
816
1452
|
}
|
|
817
1453
|
// group.* 方法注入 device_id(服务端用于多设备消息路由)
|
|
818
|
-
if (method.startsWith('group.') &&
|
|
1454
|
+
if (method.startsWith('group.') && p.device_id === undefined) {
|
|
819
1455
|
p.device_id = this._deviceId;
|
|
820
1456
|
}
|
|
821
1457
|
if (method.startsWith('group.') && p.slot_id === undefined) {
|
|
@@ -880,6 +1516,20 @@ export class AUNClient {
|
|
|
880
1516
|
return this._putMessageThoughtEncryptedV2(p);
|
|
881
1517
|
}
|
|
882
1518
|
}
|
|
1519
|
+
// Pull Gate:序列化同一 key 的 pull 操作,防止并发重复拉取
|
|
1520
|
+
const pullGateKey = this._pullGateKeyForCall(method, p);
|
|
1521
|
+
if (pullGateKey) {
|
|
1522
|
+
return await this._runPullSerialized(pullGateKey, async () => {
|
|
1523
|
+
return await this._callImplInner(method, p);
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
return await this._callImplInner(method, p);
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* _callImpl 的内层:pull gate 之后的实际 RPC 分发逻辑。
|
|
1530
|
+
* 拆分出来以便 pull gate 包裹整个操作。
|
|
1531
|
+
*/
|
|
1532
|
+
async _callImplInner(method, p) {
|
|
883
1533
|
// message.pull:V2 就绪时走 V2 pull
|
|
884
1534
|
if (method === 'message.pull' && this._v2Session) {
|
|
885
1535
|
this._clientLog.debug('call route: message.pull → V2 pull');
|
|
@@ -904,7 +1554,12 @@ export class AUNClient {
|
|
|
904
1554
|
}
|
|
905
1555
|
// 关键操作自动附加客户端签名
|
|
906
1556
|
if (SIGNED_METHODS.has(method)) {
|
|
907
|
-
|
|
1557
|
+
if (this._shouldSkipClientSignature(method, p)) {
|
|
1558
|
+
delete p.client_signature;
|
|
1559
|
+
}
|
|
1560
|
+
else {
|
|
1561
|
+
await this._signClientOperation(method, p);
|
|
1562
|
+
}
|
|
908
1563
|
}
|
|
909
1564
|
// P1-23: 非幂等方法使用更长超时
|
|
910
1565
|
const callTimeout = NON_IDEMPOTENT_METHODS.has(method) ? NON_IDEMPOTENT_TIMEOUT : undefined;
|
|
@@ -1042,6 +1697,9 @@ export class AUNClient {
|
|
|
1042
1697
|
const seq = msg.seq;
|
|
1043
1698
|
if (seq !== undefined && seq !== null && this._aid) {
|
|
1044
1699
|
const ns = `p2p:${this._aid}`;
|
|
1700
|
+
// Push 修上界:先更新 maxSeenSeq
|
|
1701
|
+
if (seq > 0)
|
|
1702
|
+
this._seqTracker.updateMaxSeen(ns, seq);
|
|
1045
1703
|
const needPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
1046
1704
|
if (needPull) {
|
|
1047
1705
|
this._safeAsync(this._fillP2pGap());
|
|
@@ -1049,8 +1707,10 @@ export class AUNClient {
|
|
|
1049
1707
|
// auto-ack contiguous_seq
|
|
1050
1708
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1051
1709
|
if (contig > 0) {
|
|
1710
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
1711
|
+
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
1052
1712
|
this._transport.call('message.ack', {
|
|
1053
|
-
seq:
|
|
1713
|
+
seq: ackSeq,
|
|
1054
1714
|
device_id: this._deviceId,
|
|
1055
1715
|
slot_id: this._slotId,
|
|
1056
1716
|
}).catch((e) => { this._clientLog.warn(`P2P auto-ack failed:${String(e)}`); });
|
|
@@ -1079,6 +1739,7 @@ export class AUNClient {
|
|
|
1079
1739
|
timestamp: (src.timestamp ?? null),
|
|
1080
1740
|
_decrypt_error: String(exc),
|
|
1081
1741
|
};
|
|
1742
|
+
attachV2EnvelopeMetadataFromSource(safeEvent, data);
|
|
1082
1743
|
await this._publishAppEvent('message.undecryptable', safeEvent);
|
|
1083
1744
|
}
|
|
1084
1745
|
}
|
|
@@ -1111,6 +1772,14 @@ export class AUNClient {
|
|
|
1111
1772
|
}
|
|
1112
1773
|
try {
|
|
1113
1774
|
const ns = `group:${groupId}`;
|
|
1775
|
+
// Push 修上界:先更新 maxSeenSeq
|
|
1776
|
+
this._seqTracker.updateMaxSeen(ns, seq);
|
|
1777
|
+
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1778
|
+
if (contigBefore === seq) {
|
|
1779
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated duplicate push already covered: group=${groupId} seq=${seq}`);
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
const afterSeq = this._repairPushContiguousBound(ns, seq, false, '_raw.group.v2.message_created');
|
|
1114
1783
|
// per-namespace 去重:同一 group namespace 只允许 1 个 in-flight pull
|
|
1115
1784
|
const dedupKey = `group_pull:${ns}`;
|
|
1116
1785
|
if (this._gapFillDone.has(dedupKey)) {
|
|
@@ -1119,7 +1788,6 @@ export class AUNClient {
|
|
|
1119
1788
|
}
|
|
1120
1789
|
this._gapFillDone.add(dedupKey);
|
|
1121
1790
|
try {
|
|
1122
|
-
const afterSeq = Math.max(0, this._seqTracker.getContiguousSeq(ns));
|
|
1123
1791
|
this._clientLog.debug(`_onRawGroupV2MessageCreated -> group.v2.pull group=${groupId} after_seq=${afterSeq}`);
|
|
1124
1792
|
const messages = await this.pullGroupV2(groupId, afterSeq, 50);
|
|
1125
1793
|
this._clientLog.debug(`_onRawGroupV2MessageCreated pulled ${messages.length} msgs for group=${groupId}`);
|
|
@@ -1164,15 +1832,20 @@ export class AUNClient {
|
|
|
1164
1832
|
// seq 跟踪 + auto-ack
|
|
1165
1833
|
if (groupId && seq !== undefined && seq !== null) {
|
|
1166
1834
|
const ns = `group:${groupId}`;
|
|
1835
|
+
// Push 修上界:先更新 maxSeenSeq
|
|
1836
|
+
if (seq > 0)
|
|
1837
|
+
this._seqTracker.updateMaxSeen(ns, seq);
|
|
1167
1838
|
const needPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
1168
1839
|
if (needPull) {
|
|
1169
1840
|
this._safeAsync(this._fillGroupGap(groupId));
|
|
1170
1841
|
}
|
|
1171
1842
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1172
1843
|
if (contig > 0) {
|
|
1844
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
1845
|
+
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
1173
1846
|
this._transport.call('group.ack_messages', {
|
|
1174
1847
|
group_id: groupId,
|
|
1175
|
-
msg_seq:
|
|
1848
|
+
msg_seq: ackSeq,
|
|
1176
1849
|
device_id: this._deviceId,
|
|
1177
1850
|
slot_id: this._slotId,
|
|
1178
1851
|
}).catch((e) => { this._clientLog.warn('group message auto-ack failed: group=' + groupId, e); });
|
|
@@ -1200,6 +1873,7 @@ export class AUNClient {
|
|
|
1200
1873
|
timestamp: (src.timestamp ?? null),
|
|
1201
1874
|
_decrypt_error: String(exc),
|
|
1202
1875
|
};
|
|
1876
|
+
attachV2EnvelopeMetadataFromSource(safeEvent, data);
|
|
1203
1877
|
await this._publishAppEvent('group.message_undecryptable', safeEvent);
|
|
1204
1878
|
}
|
|
1205
1879
|
}
|
|
@@ -1331,53 +2005,80 @@ export class AUNClient {
|
|
|
1331
2005
|
this._gapFillDone.add(dedupKey);
|
|
1332
2006
|
this._gapFillActive = true;
|
|
1333
2007
|
try {
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
2008
|
+
let nextAfterSeq = afterSeq;
|
|
2009
|
+
const maxPages = 100;
|
|
2010
|
+
let pageCount = 0;
|
|
2011
|
+
while (pageCount < maxPages) {
|
|
2012
|
+
pageCount += 1;
|
|
2013
|
+
const result = await this.call('group.pull_events', {
|
|
2014
|
+
group_id: groupId,
|
|
2015
|
+
after_event_seq: nextAfterSeq,
|
|
2016
|
+
device_id: this._deviceId,
|
|
2017
|
+
limit: 50,
|
|
2018
|
+
});
|
|
2019
|
+
if (!isJsonObject(result))
|
|
2020
|
+
return;
|
|
1341
2021
|
const events = result.events;
|
|
1342
|
-
if (Array.isArray(events))
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
if (contig > 0 && (events.length > 0 || serverAck > 0)) {
|
|
1357
|
-
this._transport.call('group.ack_events', {
|
|
1358
|
-
group_id: groupId,
|
|
1359
|
-
event_seq: contig,
|
|
1360
|
-
device_id: this._deviceId,
|
|
1361
|
-
slot_id: this._slotId,
|
|
1362
|
-
}).catch((e) => { this._clientLog.warn('group event auto-ack failed: group=' + groupId, e); });
|
|
2022
|
+
if (!Array.isArray(events))
|
|
2023
|
+
return;
|
|
2024
|
+
const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
2025
|
+
const eventObjects = events.filter(isJsonObject);
|
|
2026
|
+
if (eventObjects.length > 0) {
|
|
2027
|
+
this._seqTracker.onPullResult(ns, eventObjects, nextAfterSeq);
|
|
2028
|
+
}
|
|
2029
|
+
const cursor = isJsonObject(result.cursor) ? result.cursor : null;
|
|
2030
|
+
const serverAck = cursor ? Number(cursor.current_seq ?? 0) : 0;
|
|
2031
|
+
if (serverAck > 0) {
|
|
2032
|
+
const contigBeforeFloor = this._seqTracker.getContiguousSeq(ns);
|
|
2033
|
+
if (contigBeforeFloor < serverAck) {
|
|
2034
|
+
this._clientLog.info('group.pull_events retention-floor advance: ns=' + ns + ' contiguous=' + contigBeforeFloor + ' -> cursor.current_seq=' + serverAck);
|
|
2035
|
+
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
1363
2036
|
}
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
2037
|
+
}
|
|
2038
|
+
const eventSeqs = [];
|
|
2039
|
+
for (const evt of eventObjects) {
|
|
2040
|
+
const eventSeq = Number(evt.event_seq ?? 0);
|
|
2041
|
+
if (Number.isFinite(eventSeq) && eventSeq > 0)
|
|
2042
|
+
eventSeqs.push(eventSeq);
|
|
2043
|
+
evt._from_gap_fill = true;
|
|
2044
|
+
const et = String(evt.event_type ?? '');
|
|
2045
|
+
// 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
|
|
2046
|
+
if (et === 'group.message_created')
|
|
2047
|
+
continue;
|
|
2048
|
+
// 验签:有 client_signature 就验(与实时事件路径对齐)
|
|
2049
|
+
const cs = evt.client_signature;
|
|
2050
|
+
if (cs && typeof cs === 'object') {
|
|
2051
|
+
if (this._shouldSkipEventSignature(evt)) {
|
|
2052
|
+
delete evt.client_signature;
|
|
2053
|
+
}
|
|
2054
|
+
else {
|
|
2055
|
+
evt._verified = await this._verifyEventSignature(evt, cs);
|
|
1378
2056
|
}
|
|
1379
2057
|
}
|
|
2058
|
+
// group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
|
|
2059
|
+
await this._dispatcher.publish('group.changed', evt);
|
|
2060
|
+
}
|
|
2061
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2062
|
+
if (contig !== pageContigBefore) {
|
|
2063
|
+
this._saveSeqTrackerState();
|
|
2064
|
+
}
|
|
2065
|
+
if (eventObjects.length > 0 && contig > 0 && contig !== pageContigBefore) {
|
|
2066
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
2067
|
+
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
2068
|
+
this._transport.call('group.ack_events', {
|
|
2069
|
+
group_id: groupId,
|
|
2070
|
+
event_seq: ackSeq,
|
|
2071
|
+
device_id: this._deviceId,
|
|
2072
|
+
slot_id: this._slotId,
|
|
2073
|
+
}).catch((e) => { this._clientLog.warn('group event auto-ack failed: group=' + groupId, e); });
|
|
1380
2074
|
}
|
|
2075
|
+
const nextAfter = Math.max(eventSeqs.length > 0 ? Math.max(...eventSeqs) : nextAfterSeq, nextAfterSeq);
|
|
2076
|
+
if (eventObjects.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
|
|
2077
|
+
break;
|
|
2078
|
+
nextAfterSeq = nextAfter;
|
|
2079
|
+
}
|
|
2080
|
+
if (pageCount >= maxPages) {
|
|
2081
|
+
this._clientLog.warn(`group event gap fill reached max_pages=${maxPages} group=${groupId} after_seq=${nextAfterSeq}`);
|
|
1381
2082
|
}
|
|
1382
2083
|
}
|
|
1383
2084
|
catch (exc) {
|
|
@@ -1495,10 +2196,10 @@ export class AUNClient {
|
|
|
1495
2196
|
if (!isJsonObject(payload))
|
|
1496
2197
|
return payload;
|
|
1497
2198
|
const result = { ...payload };
|
|
1498
|
-
if (
|
|
2199
|
+
if (!('device_id' in result)) {
|
|
1499
2200
|
result.device_id = this._deviceId;
|
|
1500
2201
|
}
|
|
1501
|
-
if (
|
|
2202
|
+
if (!('slot_id' in result)) {
|
|
1502
2203
|
result.slot_id = this._slotId;
|
|
1503
2204
|
}
|
|
1504
2205
|
return result;
|
|
@@ -1554,6 +2255,18 @@ export class AUNClient {
|
|
|
1554
2255
|
const trace = `${this._echoTimestamp()} [AUN-SDK.send] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
|
|
1555
2256
|
params.payload = { ...payload, text: payload.text + '\n' + trace };
|
|
1556
2257
|
}
|
|
2258
|
+
_shouldSkipClientSignature(method, params) {
|
|
2259
|
+
if (method !== 'message.send' && method !== 'group.send')
|
|
2260
|
+
return false;
|
|
2261
|
+
if (params.encrypted || params.encrypt)
|
|
2262
|
+
return false;
|
|
2263
|
+
return this._isEchoPayload(params.payload);
|
|
2264
|
+
}
|
|
2265
|
+
_shouldSkipEventSignature(event) {
|
|
2266
|
+
if (event.encrypted || event.encrypt)
|
|
2267
|
+
return false;
|
|
2268
|
+
return this._isEchoPayload(event.payload);
|
|
2269
|
+
}
|
|
1557
2270
|
_maybeAppendEchoTraceReceive(msg) {
|
|
1558
2271
|
if (msg.encrypted)
|
|
1559
2272
|
return;
|
|
@@ -1567,13 +2280,17 @@ export class AUNClient {
|
|
|
1567
2280
|
_messageTargetsCurrentInstance(message) {
|
|
1568
2281
|
if (!isJsonObject(message))
|
|
1569
2282
|
return true;
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
2283
|
+
if ('device_id' in message) {
|
|
2284
|
+
const targetDeviceId = String(message.device_id ?? '').trim();
|
|
2285
|
+
if (targetDeviceId !== this._deviceId) {
|
|
2286
|
+
return false;
|
|
2287
|
+
}
|
|
1573
2288
|
}
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
2289
|
+
if ('slot_id' in message) {
|
|
2290
|
+
const targetSlotId = String(message.slot_id ?? '').trim();
|
|
2291
|
+
if (targetSlotId !== this._slotId) {
|
|
2292
|
+
return false;
|
|
2293
|
+
}
|
|
1577
2294
|
}
|
|
1578
2295
|
return true;
|
|
1579
2296
|
}
|
|
@@ -1668,7 +2385,12 @@ export class AUNClient {
|
|
|
1668
2385
|
// 验签:有 client_signature 就验,没有默认安全
|
|
1669
2386
|
const cs = d.client_signature;
|
|
1670
2387
|
if (cs && isJsonObject(cs)) {
|
|
1671
|
-
|
|
2388
|
+
if (this._shouldSkipEventSignature(d)) {
|
|
2389
|
+
delete d.client_signature;
|
|
2390
|
+
}
|
|
2391
|
+
else {
|
|
2392
|
+
d._verified = await this._verifyEventSignature(d, cs);
|
|
2393
|
+
}
|
|
1672
2394
|
}
|
|
1673
2395
|
await this._dispatcher.publish('group.changed', d);
|
|
1674
2396
|
const groupId = (d.group_id ?? '');
|
|
@@ -1677,14 +2399,20 @@ export class AUNClient {
|
|
|
1677
2399
|
this._v2BootstrapCache.delete(`group:${groupId}`);
|
|
1678
2400
|
}
|
|
1679
2401
|
// Group SPK 编排:成员变更触发注册/轮换
|
|
2402
|
+
const membershipActions = new Set([
|
|
2403
|
+
'member_added', 'member_left', 'member_removed', 'role_changed',
|
|
2404
|
+
'owner_transferred', 'joined', 'join_approved', 'invite_code_used',
|
|
2405
|
+
]);
|
|
1680
2406
|
if (this._v2Session && groupId) {
|
|
1681
|
-
const membershipActions = new Set([
|
|
1682
|
-
'member_added', 'member_left', 'member_removed', 'role_changed',
|
|
1683
|
-
'owner_transferred', 'joined', 'join_approved',
|
|
1684
|
-
]);
|
|
1685
2407
|
if (membershipActions.has(action)) {
|
|
1686
2408
|
const callFn = async (method, params) => this.call(method, params);
|
|
1687
|
-
|
|
2409
|
+
const joinedAid = String(d.joined_aid ?? d.member_aid ?? d.aid ?? '').trim();
|
|
2410
|
+
const actorAid = String(d.actor_aid ?? '').trim();
|
|
2411
|
+
const selfAid = String(this._aid ?? '').trim();
|
|
2412
|
+
const joinActions = new Set(['member_added', 'joined', 'join_approved', 'invite_code_used']);
|
|
2413
|
+
const isSelfJoin = joinActions.has(action) && !!selfAid && (joinedAid === selfAid ||
|
|
2414
|
+
(!joinedAid && (action === 'joined' || action === 'invite_code_used') && actorAid === selfAid));
|
|
2415
|
+
if (isSelfJoin) {
|
|
1688
2416
|
this._v2Session.ensureGroupRegistered?.(groupId, callFn)?.catch(exc => {
|
|
1689
2417
|
this._clientLog.debug(`group SPK registration failed (non-fatal): group=${groupId} action=${action} err=${exc}`);
|
|
1690
2418
|
});
|
|
@@ -1696,7 +2424,7 @@ export class AUNClient {
|
|
|
1696
2424
|
}
|
|
1697
2425
|
}
|
|
1698
2426
|
}
|
|
1699
|
-
if (groupId && action === 'upsert'
|
|
2427
|
+
if (groupId && this._v2Session && (action === 'upsert' || membershipActions.has(action))) {
|
|
1700
2428
|
this._safeAsync(this._v2AutoProposeState(groupId, { leaderDelay: true }));
|
|
1701
2429
|
}
|
|
1702
2430
|
// event_seq 空洞检测:持久化后的 group.changed 会携带 event_seq。
|
|
@@ -1761,12 +2489,17 @@ export class AUNClient {
|
|
|
1761
2489
|
// 提交者签名验证
|
|
1762
2490
|
const cs = d.client_signature;
|
|
1763
2491
|
if (cs && isJsonObject(cs)) {
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
2492
|
+
if (this._shouldSkipEventSignature(d)) {
|
|
2493
|
+
delete d.client_signature;
|
|
2494
|
+
}
|
|
2495
|
+
else {
|
|
2496
|
+
const verified = await this._verifyEventSignature(d, cs);
|
|
2497
|
+
if (verified === false) {
|
|
2498
|
+
this._clientLog.warn(`state_committed committer signature verify failed group=%s${String(groupId)}`);
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
d._verified = verified;
|
|
1768
2502
|
}
|
|
1769
|
-
d._verified = verified;
|
|
1770
2503
|
}
|
|
1771
2504
|
const stateVersion = Number(d.state_version ?? 0);
|
|
1772
2505
|
const stateHash = String(d.state_hash ?? '').trim();
|
|
@@ -2036,6 +2769,7 @@ export class AUNClient {
|
|
|
2036
2769
|
let e2eeMeta = null;
|
|
2037
2770
|
let decryptFailed = false;
|
|
2038
2771
|
if (isV2Envelope) {
|
|
2772
|
+
e2eeMeta = v2E2eeMeta(payload);
|
|
2039
2773
|
const plaintext = await this._decryptV2EnvelopeForThought({
|
|
2040
2774
|
envelope: payload,
|
|
2041
2775
|
fromAid: senderAid,
|
|
@@ -2047,7 +2781,7 @@ export class AUNClient {
|
|
|
2047
2781
|
}
|
|
2048
2782
|
else {
|
|
2049
2783
|
decryptedPayload = plaintext;
|
|
2050
|
-
const e2eeObj =
|
|
2784
|
+
const e2eeObj = e2eeMeta;
|
|
2051
2785
|
// 暴露 protected_headers(去 _auth)
|
|
2052
2786
|
const ph = payload.protected_headers;
|
|
2053
2787
|
if (isJsonObject(ph)) {
|
|
@@ -2084,6 +2818,8 @@ export class AUNClient {
|
|
|
2084
2818
|
created_at: item.created_at,
|
|
2085
2819
|
e2ee: e2eeMeta,
|
|
2086
2820
|
};
|
|
2821
|
+
if (isJsonObject(e2eeMeta))
|
|
2822
|
+
attachV2EnvelopeMetadata(thought, e2eeMeta);
|
|
2087
2823
|
if (decryptFailed)
|
|
2088
2824
|
thought.decrypt_failed = true;
|
|
2089
2825
|
if ('context' in item)
|
|
@@ -2122,6 +2858,8 @@ export class AUNClient {
|
|
|
2122
2858
|
let decryptFailed = false;
|
|
2123
2859
|
// V2 P2P thought envelope:per-device wrap,本设备解密自己的 row
|
|
2124
2860
|
if (payload?.type === 'e2ee.p2p_encrypted') {
|
|
2861
|
+
const e2eeObj = v2E2eeMeta(payload);
|
|
2862
|
+
message.e2ee = e2eeObj;
|
|
2125
2863
|
const plaintext = await this._decryptV2EnvelopeForThought({
|
|
2126
2864
|
envelope: payload,
|
|
2127
2865
|
fromAid,
|
|
@@ -2133,7 +2871,6 @@ export class AUNClient {
|
|
|
2133
2871
|
else {
|
|
2134
2872
|
decrypted = { ...message };
|
|
2135
2873
|
decrypted.payload = plaintext;
|
|
2136
|
-
const e2eeObj = v2E2eeMeta(payload);
|
|
2137
2874
|
// 暴露 protected_headers(去 _auth)
|
|
2138
2875
|
const ph = payload.protected_headers;
|
|
2139
2876
|
if (isJsonObject(ph)) {
|
|
@@ -2162,6 +2899,7 @@ export class AUNClient {
|
|
|
2162
2899
|
else if (payload?.type === 'e2ee.encrypted') {
|
|
2163
2900
|
decryptFailed = true;
|
|
2164
2901
|
}
|
|
2902
|
+
const exposedE2ee = (decrypted ?? message).e2ee;
|
|
2165
2903
|
const thought = {
|
|
2166
2904
|
thought_id: thoughtId,
|
|
2167
2905
|
message_id: thoughtId,
|
|
@@ -2169,8 +2907,10 @@ export class AUNClient {
|
|
|
2169
2907
|
to: toAid,
|
|
2170
2908
|
payload: (decrypted ?? message).payload,
|
|
2171
2909
|
created_at: item.created_at,
|
|
2172
|
-
e2ee:
|
|
2910
|
+
e2ee: exposedE2ee,
|
|
2173
2911
|
};
|
|
2912
|
+
if (isJsonObject(exposedE2ee))
|
|
2913
|
+
attachV2EnvelopeMetadata(thought, exposedE2ee);
|
|
2174
2914
|
if (decryptFailed)
|
|
2175
2915
|
thought.decrypt_failed = true;
|
|
2176
2916
|
if ('context' in item)
|
|
@@ -2184,7 +2924,7 @@ export class AUNClient {
|
|
|
2184
2924
|
* 获取对方证书(带缓存 + 完整 PKI 验证:链 + CRL + OCSP + AID 绑定)。
|
|
2185
2925
|
* 跨域时自动将请求路由到 peer 所在域的 Gateway。
|
|
2186
2926
|
*/
|
|
2187
|
-
async _fetchPeerCert(aid, certFingerprint) {
|
|
2927
|
+
async _fetchPeerCert(aid, certFingerprint, timeoutMs = 5000) {
|
|
2188
2928
|
const tStart = Date.now();
|
|
2189
2929
|
this._clientLog.debug(`_fetchPeerCert enter: aid=${aid} fingerprint=${certFingerprint ?? '<none>'}`);
|
|
2190
2930
|
try {
|
|
@@ -2206,7 +2946,7 @@ export class AUNClient {
|
|
|
2206
2946
|
const certUrl = buildCertUrl(peerGatewayUrl, aid, certFingerprint);
|
|
2207
2947
|
// 兼容旧浏览器,不使用 AbortSignal.timeout(Chrome 103+ 才支持)
|
|
2208
2948
|
const controller = new AbortController();
|
|
2209
|
-
const timeoutId = setTimeout(() => controller.abort(),
|
|
2949
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
2210
2950
|
try {
|
|
2211
2951
|
const resp = await fetch(certUrl, { signal: controller.signal });
|
|
2212
2952
|
if (!resp.ok)
|
|
@@ -2223,7 +2963,7 @@ export class AUNClient {
|
|
|
2223
2963
|
}
|
|
2224
2964
|
// 兼容旧浏览器,不使用 AbortSignal.timeout(Chrome 103+ 才支持)
|
|
2225
2965
|
const fallbackController = new AbortController();
|
|
2226
|
-
const fallbackTimeoutId = setTimeout(() => fallbackController.abort(),
|
|
2966
|
+
const fallbackTimeoutId = setTimeout(() => fallbackController.abort(), timeoutMs);
|
|
2227
2967
|
try {
|
|
2228
2968
|
const fallbackResp = await fetch(buildCertUrl(peerGatewayUrl, aid), { signal: fallbackController.signal });
|
|
2229
2969
|
if (!fallbackResp.ok) {
|
|
@@ -2485,6 +3225,10 @@ export class AUNClient {
|
|
|
2485
3225
|
}
|
|
2486
3226
|
}
|
|
2487
3227
|
_resolveGateway(params) {
|
|
3228
|
+
const gateways = this._resolveGateways(params);
|
|
3229
|
+
return gateways[0];
|
|
3230
|
+
}
|
|
3231
|
+
_resolveGateways(params) {
|
|
2488
3232
|
const topology = isJsonObject(params.topology) ? params.topology : null;
|
|
2489
3233
|
if (topology) {
|
|
2490
3234
|
const mode = String(topology.mode ?? 'gateway');
|
|
@@ -2495,10 +3239,16 @@ export class AUNClient {
|
|
|
2495
3239
|
throw new ValidationError('relay topology is not implemented in the Browser SDK');
|
|
2496
3240
|
}
|
|
2497
3241
|
}
|
|
2498
|
-
const
|
|
3242
|
+
const gw = params.gateway ?? params.gateways;
|
|
3243
|
+
if (Array.isArray(gw)) {
|
|
3244
|
+
const urls = gw.map((g) => String(g ?? '')).filter((u) => u.length > 0);
|
|
3245
|
+
if (urls.length > 0)
|
|
3246
|
+
return urls;
|
|
3247
|
+
}
|
|
3248
|
+
const gateway = String(gw ?? this._gatewayUrl ?? '');
|
|
2499
3249
|
if (!gateway)
|
|
2500
3250
|
throw new StateError('missing gateway in connect params');
|
|
2501
|
-
return gateway;
|
|
3251
|
+
return [gateway];
|
|
2502
3252
|
}
|
|
2503
3253
|
async _syncIdentityAfterConnect(accessToken) {
|
|
2504
3254
|
let identity = null;
|
|
@@ -2787,6 +3537,16 @@ export class AUNClient {
|
|
|
2787
3537
|
};
|
|
2788
3538
|
scheduleRefresh(0);
|
|
2789
3539
|
}
|
|
3540
|
+
_normalizeOutboundMessagePayload(params, method = '') {
|
|
3541
|
+
if (!Object.prototype.hasOwnProperty.call(params, 'payload') && Object.prototype.hasOwnProperty.call(params, 'content')) {
|
|
3542
|
+
params.payload = params.content;
|
|
3543
|
+
delete params.content;
|
|
3544
|
+
}
|
|
3545
|
+
const payload = params.payload;
|
|
3546
|
+
if (isJsonObject(payload) && !Object.prototype.hasOwnProperty.call(payload, 'type') && typeof payload.text === 'string') {
|
|
3547
|
+
params.payload = { type: 'text', ...payload };
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
2790
3550
|
_validateMessageRecipient(toAid) {
|
|
2791
3551
|
if (isGroupServiceAid(toAid)) {
|
|
2792
3552
|
throw new ValidationError('message.send receiver cannot be group.{issuer}; use group.send instead');
|
|
@@ -3210,6 +3970,8 @@ export class AUNClient {
|
|
|
3210
3970
|
this._gapFillDone.clear();
|
|
3211
3971
|
this._pushedSeqs.clear();
|
|
3212
3972
|
this._pendingOrderedMsgs.clear();
|
|
3973
|
+
this._v2SenderIKPending.clear();
|
|
3974
|
+
this._v2SenderIKFetching.clear();
|
|
3213
3975
|
this._groupSynced.clear();
|
|
3214
3976
|
}
|
|
3215
3977
|
_refreshSeqTrackerContext() {
|
|
@@ -3220,6 +3982,8 @@ export class AUNClient {
|
|
|
3220
3982
|
this._gapFillDone.clear();
|
|
3221
3983
|
this._pushedSeqs.clear();
|
|
3222
3984
|
this._pendingOrderedMsgs.clear();
|
|
3985
|
+
this._v2SenderIKPending.clear();
|
|
3986
|
+
this._v2SenderIKFetching.clear();
|
|
3223
3987
|
this._groupSynced.clear();
|
|
3224
3988
|
this._seqTrackerContext = nextContext;
|
|
3225
3989
|
}
|
|
@@ -3273,13 +4037,54 @@ export class AUNClient {
|
|
|
3273
4037
|
}).catch(() => { });
|
|
3274
4038
|
}
|
|
3275
4039
|
}
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
4040
|
+
_persistRepairedSeq(ns) {
|
|
4041
|
+
if (!this._aid || !ns)
|
|
4042
|
+
return;
|
|
4043
|
+
const seq = this._seqTracker.getContiguousSeq(ns);
|
|
4044
|
+
try {
|
|
4045
|
+
if (seq > 0 && typeof this._keystore.saveSeq === 'function') {
|
|
4046
|
+
this._keystore.saveSeq(this._aid, this._deviceId, this._slotId, ns, seq).catch((exc) => {
|
|
4047
|
+
this._clientLog.debug(`persist repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
|
|
4048
|
+
});
|
|
4049
|
+
return;
|
|
4050
|
+
}
|
|
4051
|
+
const deleteSeq = this._keystore.deleteSeq;
|
|
4052
|
+
if (seq <= 0 && typeof deleteSeq === 'function') {
|
|
4053
|
+
deleteSeq.call(this._keystore, this._aid, this._deviceId, this._slotId, ns).catch((exc) => {
|
|
4054
|
+
this._clientLog.debug(`delete repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
|
|
4055
|
+
});
|
|
4056
|
+
return;
|
|
4057
|
+
}
|
|
4058
|
+
if (seq > 0) {
|
|
4059
|
+
this._saveSeqTrackerState();
|
|
4060
|
+
}
|
|
4061
|
+
}
|
|
4062
|
+
catch (exc) {
|
|
4063
|
+
this._clientLog.debug(`persist repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
|
|
4064
|
+
}
|
|
4065
|
+
}
|
|
4066
|
+
_repairPushContiguousBound(ns, pushSeq, hasPayload, label) {
|
|
4067
|
+
if (!ns || !Number.isFinite(pushSeq) || pushSeq <= 0) {
|
|
4068
|
+
return ns ? this._seqTracker.getContiguousSeq(ns) : 0;
|
|
4069
|
+
}
|
|
4070
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
4071
|
+
const shouldRepair = contig > pushSeq;
|
|
4072
|
+
if (!shouldRepair)
|
|
4073
|
+
return contig;
|
|
4074
|
+
const repairedTo = Math.max(0, pushSeq - 1);
|
|
4075
|
+
this._seqTracker.repairContiguousSeq(ns, repairedTo);
|
|
4076
|
+
const repaired = this._seqTracker.getContiguousSeq(ns);
|
|
4077
|
+
this._persistRepairedSeq(ns);
|
|
4078
|
+
this._clientLog.warn(`${label} push repaired contiguous_seq: ns=${ns} payload=${hasPayload} push_seq=${pushSeq} contiguous=${contig}->${repaired}`);
|
|
4079
|
+
return repaired;
|
|
4080
|
+
}
|
|
4081
|
+
// ── V2 E2EE API(async,与 Python `client.py` `_init_v2_session` / `send_v2` / `pull_v2` / `ack_v2` 对齐) ──
|
|
4082
|
+
/**
|
|
4083
|
+
* 初始化 V2 session:从 AID PEM 私钥提取 raw scalar + DER 公钥,
|
|
4084
|
+
* 打开 V2 KeyStore(IndexedDB),构造 V2Session 并注册当前设备 SPK。
|
|
4085
|
+
*
|
|
4086
|
+
* connect 成功后自动调用,可幂等手动调用。
|
|
4087
|
+
*/
|
|
3283
4088
|
async initV2Session() {
|
|
3284
4089
|
if (!this._aid)
|
|
3285
4090
|
return;
|
|
@@ -3323,6 +4128,104 @@ export class AUNClient {
|
|
|
3323
4128
|
// 上线时自动确认 pending state proposals
|
|
3324
4129
|
this._safeAsync(this._v2AutoConfirmPendingProposals());
|
|
3325
4130
|
}
|
|
4131
|
+
async _v2TrustedIKPubDer(aid) {
|
|
4132
|
+
const normalizedAid = String(aid ?? '').trim();
|
|
4133
|
+
if (!normalizedAid)
|
|
4134
|
+
throw new E2EEError('spk_aid_missing');
|
|
4135
|
+
if (this._aid && normalizedAid === this._aid) {
|
|
4136
|
+
if (!this._v2Session)
|
|
4137
|
+
throw new E2EEError('V2 session not initialized');
|
|
4138
|
+
return this._v2Session.currentIkPubDer;
|
|
4139
|
+
}
|
|
4140
|
+
const certPem = await this._fetchPeerCert(normalizedAid);
|
|
4141
|
+
const pubKey = await importCertPublicKeyEcdsa(certPem);
|
|
4142
|
+
return new Uint8Array(await crypto.subtle.exportKey('spki', pubKey));
|
|
4143
|
+
}
|
|
4144
|
+
_v2SPKTimestampText(value, aid, deviceId, spkId) {
|
|
4145
|
+
if (value === null || value === undefined || value === '') {
|
|
4146
|
+
throw new E2EEError(`spk_timestamp_missing: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
|
|
4147
|
+
}
|
|
4148
|
+
if (typeof value === 'boolean') {
|
|
4149
|
+
throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
|
|
4150
|
+
}
|
|
4151
|
+
if (typeof value === 'number') {
|
|
4152
|
+
if (!Number.isSafeInteger(value)) {
|
|
4153
|
+
throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
|
|
4154
|
+
}
|
|
4155
|
+
return String(value);
|
|
4156
|
+
}
|
|
4157
|
+
const text = String(value).trim();
|
|
4158
|
+
if (!/^\d+$/.test(text)) {
|
|
4159
|
+
throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
|
|
4160
|
+
}
|
|
4161
|
+
return BigInt(text).toString();
|
|
4162
|
+
}
|
|
4163
|
+
async _v2VerifySPKDevice(args) {
|
|
4164
|
+
if (!this._v2Session)
|
|
4165
|
+
throw new E2EEError('V2 session not initialized');
|
|
4166
|
+
const spkId = String(args.dev.spk_id ?? '').trim();
|
|
4167
|
+
if (!spkId)
|
|
4168
|
+
return;
|
|
4169
|
+
if (args.keySource !== 'peer_device_prekey' && args.keySource !== 'group_device_prekey') {
|
|
4170
|
+
throw new E2EEError(`spk_key_source_invalid: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId} key_source=${args.keySource}`);
|
|
4171
|
+
}
|
|
4172
|
+
if (!args.spkPkDer || args.spkPkDer.length === 0) {
|
|
4173
|
+
throw new E2EEError(`spk_public_key_missing: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
4174
|
+
}
|
|
4175
|
+
const spkHash = bytesToHex(new Uint8Array(await crypto.subtle.digest('SHA-256', args.spkPkDer.slice().buffer)));
|
|
4176
|
+
const expectedSpkId = `sha256:${spkHash.substring(0, 16)}`;
|
|
4177
|
+
if (spkId !== expectedSpkId) {
|
|
4178
|
+
throw new E2EEError(`spk_id_mismatch: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId} expected=${expectedSpkId}`);
|
|
4179
|
+
}
|
|
4180
|
+
const trustedIK = await this._v2TrustedIKPubDer(args.aid);
|
|
4181
|
+
if (!_v2BytesEqual(trustedIK, args.ikPkDer)) {
|
|
4182
|
+
throw new E2EEError(`spk_ik_mismatch: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
4183
|
+
}
|
|
4184
|
+
if (_v2BytesEqual(args.spkPkDer, trustedIK)) {
|
|
4185
|
+
this._v2Session.markPeerSPKVerified(args.aid, args.deviceId, spkId);
|
|
4186
|
+
return;
|
|
4187
|
+
}
|
|
4188
|
+
const sigB64 = String(args.dev.spk_signature ?? '').trim();
|
|
4189
|
+
if (!sigB64) {
|
|
4190
|
+
throw new E2EEError(`spk_signature_missing: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
4191
|
+
}
|
|
4192
|
+
let signature;
|
|
4193
|
+
try {
|
|
4194
|
+
signature = _v2B64ToBytesStrict(sigB64);
|
|
4195
|
+
}
|
|
4196
|
+
catch {
|
|
4197
|
+
throw new E2EEError(`spk_signature_invalid_base64: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
4198
|
+
}
|
|
4199
|
+
const encoder = new TextEncoder();
|
|
4200
|
+
const tsText = this._v2SPKTimestampText(args.dev.spk_timestamp, args.aid, args.deviceId, spkId);
|
|
4201
|
+
const signData = _v2ConcatBytes(args.spkPkDer, encoder.encode(spkId), encoder.encode(tsText));
|
|
4202
|
+
if (!(await ecdsaVerifyRaw(trustedIK, signature, signData))) {
|
|
4203
|
+
throw new E2EEError(`spk_signature_invalid: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
4204
|
+
}
|
|
4205
|
+
this._v2Session.markPeerSPKVerified(args.aid, args.deviceId, spkId);
|
|
4206
|
+
}
|
|
4207
|
+
async _v2BuildTargetFromDevice(args) {
|
|
4208
|
+
const aid = String(args.aid ?? '').trim();
|
|
4209
|
+
const devId = getV2DeviceId(args.dev);
|
|
4210
|
+
const deviceId = devId.present ? devId.value : String(args.deviceId ?? '').trim();
|
|
4211
|
+
const ikPk = String(args.dev.ik_pk ?? '').trim();
|
|
4212
|
+
if (!aid || !devId.present || !ikPk)
|
|
4213
|
+
return null;
|
|
4214
|
+
const ikPkDer = _v2B64ToBytes(ikPk);
|
|
4215
|
+
const spkPkDer = args.dev.spk_pk ? _v2B64ToBytes(String(args.dev.spk_pk)) : undefined;
|
|
4216
|
+
const keySource = String(args.dev.key_source ?? args.defaultKeySource).trim() || args.defaultKeySource;
|
|
4217
|
+
await this._v2VerifySPKDevice({ dev: args.dev, aid, deviceId, ikPkDer, spkPkDer, keySource });
|
|
4218
|
+
this._v2Session?.cachePeerIK(aid, deviceId, ikPkDer);
|
|
4219
|
+
return {
|
|
4220
|
+
aid,
|
|
4221
|
+
deviceId,
|
|
4222
|
+
role: args.role,
|
|
4223
|
+
keySource,
|
|
4224
|
+
ikPkDer,
|
|
4225
|
+
spkPkDer,
|
|
4226
|
+
spkId: String(args.dev.spk_id ?? '').trim(),
|
|
4227
|
+
};
|
|
4228
|
+
}
|
|
3326
4229
|
async _getV2SenderPubDer(fromAid, senderDeviceId) {
|
|
3327
4230
|
const session = this._v2Session;
|
|
3328
4231
|
if (!session || !fromAid)
|
|
@@ -3331,37 +4234,134 @@ export class AUNClient {
|
|
|
3331
4234
|
if (senderPubDer)
|
|
3332
4235
|
return senderPubDer;
|
|
3333
4236
|
try {
|
|
3334
|
-
const
|
|
3335
|
-
const peers = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
|
|
3336
|
-
for (const dev of peers) {
|
|
3337
|
-
const devId = String(dev.device_id ?? dev.owner_device_id ?? '');
|
|
3338
|
-
const ikPk = String(dev.ik_pk ?? '');
|
|
3339
|
-
if (!devId || !ikPk)
|
|
3340
|
-
continue;
|
|
3341
|
-
session.cachePeerIK(fromAid, devId, _v2B64ToBytes(ikPk));
|
|
3342
|
-
}
|
|
3343
|
-
senderPubDer = session.getPeerIK(fromAid, senderDeviceId);
|
|
3344
|
-
if (senderPubDer)
|
|
3345
|
-
return senderPubDer;
|
|
3346
|
-
}
|
|
3347
|
-
catch (exc) {
|
|
3348
|
-
this._clientLog.warn(`V2 decrypt: bootstrap for sender ${fromAid} failed: ${String(formatCaughtError(exc))}`);
|
|
3349
|
-
}
|
|
3350
|
-
try {
|
|
3351
|
-
const certPem = await this._fetchPeerCert(fromAid);
|
|
4237
|
+
const certPem = await this._fetchPeerCert(fromAid, undefined, 3000);
|
|
3352
4238
|
const pubKey = await importCertPublicKeyEcdsa(certPem);
|
|
3353
4239
|
senderPubDer = new Uint8Array(await crypto.subtle.exportKey('spki', pubKey));
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
}
|
|
3357
|
-
this._clientLog.debug(`V2 decrypt: sender IK fallback from CA cert for ${fromAid}`);
|
|
4240
|
+
session.cachePeerIK(fromAid, senderDeviceId, senderPubDer);
|
|
4241
|
+
this._clientLog.debug(`V2 decrypt: sender IK fallback from PKI cert for ${fromAid}`);
|
|
3358
4242
|
return senderPubDer;
|
|
3359
4243
|
}
|
|
3360
4244
|
catch (exc) {
|
|
3361
|
-
this._clientLog.warn(`V2 decrypt:
|
|
4245
|
+
this._clientLog.warn(`V2 decrypt: PKI cert sender IK fallback failed for ${fromAid}: ${String(formatCaughtError(exc))}`);
|
|
3362
4246
|
return null;
|
|
3363
4247
|
}
|
|
3364
4248
|
}
|
|
4249
|
+
_v2PendingSenderIKMessageKey(msg, groupId) {
|
|
4250
|
+
const messageId = String(msg.message_id ?? '').trim();
|
|
4251
|
+
const seq = String(msg.seq ?? '').trim();
|
|
4252
|
+
const prefix = groupId ? `group:${groupId}` : `p2p:${this._aid ?? ''}`;
|
|
4253
|
+
return `${prefix}:${messageId || seq || Math.random().toString(36).slice(2)}`;
|
|
4254
|
+
}
|
|
4255
|
+
_v2PendingSenderIKFetchKey(fromAid, senderDeviceId, groupId) {
|
|
4256
|
+
return `${fromAid}#${senderDeviceId}#${groupId || ''}`;
|
|
4257
|
+
}
|
|
4258
|
+
_cacheV2PeerIKFromDevice(dev, fallbackAid = '') {
|
|
4259
|
+
const session = this._v2Session;
|
|
4260
|
+
if (!session || !isJsonObject(dev))
|
|
4261
|
+
return;
|
|
4262
|
+
const device = dev;
|
|
4263
|
+
const devId = getV2DeviceId(device);
|
|
4264
|
+
const aid = String(device.aid ?? fallbackAid ?? '').trim();
|
|
4265
|
+
const ikPk = String(device.ik_pk ?? '').trim();
|
|
4266
|
+
if (!devId.present || !aid || !ikPk)
|
|
4267
|
+
return;
|
|
4268
|
+
try {
|
|
4269
|
+
session.cachePeerIK(aid, devId.value, _v2B64ToBytes(ikPk));
|
|
4270
|
+
}
|
|
4271
|
+
catch (exc) {
|
|
4272
|
+
this._clientLog.debug(`V2 sender IK cache from bootstrap skipped aid=${aid} dev=${devId.value}: ${String(formatCaughtError(exc))}`);
|
|
4273
|
+
}
|
|
4274
|
+
}
|
|
4275
|
+
_scheduleV2SenderIKPending(args) {
|
|
4276
|
+
const fromAid = String(args.fromAid ?? '').trim();
|
|
4277
|
+
if (!fromAid)
|
|
4278
|
+
return;
|
|
4279
|
+
const senderDeviceId = String(args.senderDeviceId ?? '');
|
|
4280
|
+
const groupId = String(args.groupId ?? '').trim();
|
|
4281
|
+
const messageKey = this._v2PendingSenderIKMessageKey(args.msg, groupId);
|
|
4282
|
+
this._v2SenderIKPending.set(messageKey, {
|
|
4283
|
+
msg: { ...args.msg },
|
|
4284
|
+
fromAid,
|
|
4285
|
+
senderDeviceId,
|
|
4286
|
+
groupId,
|
|
4287
|
+
createdAt: Date.now(),
|
|
4288
|
+
});
|
|
4289
|
+
this._clientLog.debug(`V2 decrypt pending sender IK: key=${messageKey} from=${fromAid} device=${senderDeviceId || '-'} group=${groupId || '<p2p>'} pending=${this._v2SenderIKPending.size}`);
|
|
4290
|
+
this._scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupId);
|
|
4291
|
+
}
|
|
4292
|
+
_scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupId) {
|
|
4293
|
+
const fetchKey = this._v2PendingSenderIKFetchKey(fromAid, senderDeviceId, groupId);
|
|
4294
|
+
if (!fromAid || this._v2SenderIKFetching.has(fetchKey))
|
|
4295
|
+
return;
|
|
4296
|
+
this._v2SenderIKFetching.add(fetchKey);
|
|
4297
|
+
this._safeAsync(this._resolveV2SenderIKPending(fromAid, senderDeviceId, groupId, fetchKey));
|
|
4298
|
+
}
|
|
4299
|
+
async _resolveV2SenderIKPending(fromAid, senderDeviceId, groupId, fetchKey) {
|
|
4300
|
+
try {
|
|
4301
|
+
const session = this._v2Session;
|
|
4302
|
+
if (session && fromAid) {
|
|
4303
|
+
try {
|
|
4304
|
+
const bs = await this.call('message.v2.bootstrap', {
|
|
4305
|
+
peer_aid: fromAid,
|
|
4306
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
4307
|
+
});
|
|
4308
|
+
const peers = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
|
|
4309
|
+
for (const dev of peers)
|
|
4310
|
+
this._cacheV2PeerIKFromDevice(dev, fromAid);
|
|
4311
|
+
}
|
|
4312
|
+
catch (exc) {
|
|
4313
|
+
this._clientLog.warn(`V2 sender IK pending bootstrap failed peer=${fromAid}: ${String(formatCaughtError(exc))}`);
|
|
4314
|
+
}
|
|
4315
|
+
if (groupId) {
|
|
4316
|
+
try {
|
|
4317
|
+
const gbs = await this.call('group.v2.bootstrap', {
|
|
4318
|
+
group_id: groupId,
|
|
4319
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
4320
|
+
});
|
|
4321
|
+
const devices = (Array.isArray(gbs?.devices) ? gbs.devices : []);
|
|
4322
|
+
const audit = (Array.isArray(gbs?.audit_recipients) ? gbs.audit_recipients : []);
|
|
4323
|
+
for (const dev of devices)
|
|
4324
|
+
this._cacheV2PeerIKFromDevice(dev);
|
|
4325
|
+
for (const dev of audit)
|
|
4326
|
+
this._cacheV2PeerIKFromDevice(dev);
|
|
4327
|
+
}
|
|
4328
|
+
catch (exc) {
|
|
4329
|
+
this._clientLog.warn(`V2 sender IK pending group bootstrap failed group=${groupId}: ${String(formatCaughtError(exc))}`);
|
|
4330
|
+
}
|
|
4331
|
+
}
|
|
4332
|
+
if (!session.getPeerIK(fromAid, senderDeviceId)) {
|
|
4333
|
+
await this._getV2SenderPubDer(fromAid, senderDeviceId);
|
|
4334
|
+
}
|
|
4335
|
+
}
|
|
4336
|
+
const pendingItems = [...this._v2SenderIKPending.entries()].filter(([, entry]) => entry.fromAid === fromAid && entry.senderDeviceId === senderDeviceId && entry.groupId === groupId);
|
|
4337
|
+
for (const [key, entry] of pendingItems) {
|
|
4338
|
+
let plaintext = null;
|
|
4339
|
+
try {
|
|
4340
|
+
plaintext = await this._decryptV2Message(entry.msg, false);
|
|
4341
|
+
}
|
|
4342
|
+
catch (exc) {
|
|
4343
|
+
this._clientLog.warn(`V2 sender IK pending retry raised: key=${key} err=${String(formatCaughtError(exc))}`);
|
|
4344
|
+
}
|
|
4345
|
+
this._v2SenderIKPending.delete(key);
|
|
4346
|
+
if (plaintext === null) {
|
|
4347
|
+
this._clientLog.debug(`V2 sender IK pending retry failed: key=${key}`);
|
|
4348
|
+
continue;
|
|
4349
|
+
}
|
|
4350
|
+
const seq = Number(entry.msg.seq ?? 0);
|
|
4351
|
+
if (entry.groupId) {
|
|
4352
|
+
plaintext.group_id = entry.groupId;
|
|
4353
|
+
await this._publishPulledMessage('group.message_created', `group:${entry.groupId}`, seq, plaintext);
|
|
4354
|
+
}
|
|
4355
|
+
else {
|
|
4356
|
+
await this._publishPulledMessage('message.received', `p2p:${this._aid ?? ''}`, seq, plaintext);
|
|
4357
|
+
}
|
|
4358
|
+
this._clientLog.debug(`V2 sender IK pending retry delivered: key=${key}`);
|
|
4359
|
+
}
|
|
4360
|
+
}
|
|
4361
|
+
finally {
|
|
4362
|
+
this._v2SenderIKFetching.delete(fetchKey);
|
|
4363
|
+
}
|
|
4364
|
+
}
|
|
3365
4365
|
/**
|
|
3366
4366
|
* V2 P2P 加密发送(推测性:用缓存 bootstrap 直接发,失败刷新重试一次)。
|
|
3367
4367
|
*
|
|
@@ -3374,108 +4374,21 @@ export class AUNClient {
|
|
|
3374
4374
|
if (!this._v2Session) {
|
|
3375
4375
|
throw new StateError('V2 session not initialized (not connected?)');
|
|
3376
4376
|
}
|
|
3377
|
-
const session = this._v2Session;
|
|
3378
4377
|
const toAid = String(to ?? '').trim();
|
|
3379
4378
|
if (!toAid)
|
|
3380
4379
|
throw new ValidationError("message.send requires 'to'");
|
|
3381
4380
|
if (!isJsonObject(payload))
|
|
3382
4381
|
throw new ValidationError('message.send payload must be a dict for V2 encryption');
|
|
3383
4382
|
const attempt = async (useCache) => {
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
peerDevices = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
|
|
3394
|
-
auditRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
|
|
3395
|
-
if (peerDevices.length > 0) {
|
|
3396
|
-
this._v2BootstrapCache.set(toAid, {
|
|
3397
|
-
devices: peerDevices,
|
|
3398
|
-
auditRecipients: auditRaw,
|
|
3399
|
-
cachedAt: Date.now(),
|
|
3400
|
-
});
|
|
3401
|
-
}
|
|
3402
|
-
}
|
|
3403
|
-
if (peerDevices.length === 0) {
|
|
3404
|
-
throw new E2EEError(`V2 bootstrap: no devices found for ${toAid}`);
|
|
3405
|
-
}
|
|
3406
|
-
const targets = [];
|
|
3407
|
-
for (const dev of peerDevices) {
|
|
3408
|
-
const ikDer = _v2B64ToBytes(String(dev.ik_pk ?? ''));
|
|
3409
|
-
const spkDer = dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined;
|
|
3410
|
-
session.cachePeerIK(toAid, String(dev.device_id ?? ''), ikDer);
|
|
3411
|
-
targets.push({
|
|
3412
|
-
aid: toAid,
|
|
3413
|
-
deviceId: String(dev.device_id ?? ''),
|
|
3414
|
-
role: 'peer',
|
|
3415
|
-
keySource: String(dev.key_source ?? 'peer_device_prekey'),
|
|
3416
|
-
ikPkDer: ikDer,
|
|
3417
|
-
spkPkDer: spkDer,
|
|
3418
|
-
spkId: String(dev.spk_id ?? ''),
|
|
3419
|
-
});
|
|
3420
|
-
}
|
|
3421
|
-
const auditTargets = [];
|
|
3422
|
-
for (const dev of auditRaw) {
|
|
3423
|
-
if (!dev.ik_pk)
|
|
3424
|
-
continue;
|
|
3425
|
-
const ikDer = _v2B64ToBytes(String(dev.ik_pk));
|
|
3426
|
-
const spkDer = dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined;
|
|
3427
|
-
auditTargets.push({
|
|
3428
|
-
aid: String(dev.aid ?? ''),
|
|
3429
|
-
deviceId: String(dev.device_id ?? ''),
|
|
3430
|
-
role: 'audit',
|
|
3431
|
-
keySource: String(dev.key_source ?? 'peer_device_prekey'),
|
|
3432
|
-
ikPkDer: ikDer,
|
|
3433
|
-
spkPkDer: spkDer,
|
|
3434
|
-
spkId: String(dev.spk_id ?? ''),
|
|
3435
|
-
});
|
|
3436
|
-
}
|
|
3437
|
-
// self-sync:当前 AID 的其它设备
|
|
3438
|
-
if (this._aid && this._aid !== toAid) {
|
|
3439
|
-
try {
|
|
3440
|
-
const selfCached = this._v2BootstrapCache.get(this._aid);
|
|
3441
|
-
let selfDevices = [];
|
|
3442
|
-
if (selfCached && (Date.now() - selfCached.cachedAt) < AUNClient.V2_BOOTSTRAP_TTL_MS) {
|
|
3443
|
-
selfDevices = selfCached.devices;
|
|
3444
|
-
}
|
|
3445
|
-
else {
|
|
3446
|
-
const selfBs = await this.call('message.v2.bootstrap', { peer_aid: this._aid });
|
|
3447
|
-
selfDevices = (Array.isArray(selfBs?.peer_devices) ? selfBs.peer_devices : []);
|
|
3448
|
-
if (selfDevices.length > 0) {
|
|
3449
|
-
this._v2BootstrapCache.set(this._aid, {
|
|
3450
|
-
devices: selfDevices,
|
|
3451
|
-
auditRecipients: [],
|
|
3452
|
-
cachedAt: Date.now(),
|
|
3453
|
-
});
|
|
3454
|
-
}
|
|
3455
|
-
}
|
|
3456
|
-
for (const dev of selfDevices) {
|
|
3457
|
-
const devId = String(dev.owner_device_id ?? dev.device_id ?? '');
|
|
3458
|
-
if (devId === this._deviceId)
|
|
3459
|
-
continue;
|
|
3460
|
-
const ikDer = _v2B64ToBytes(String(dev.ik_pk ?? ''));
|
|
3461
|
-
const spkDer = dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined;
|
|
3462
|
-
targets.push({
|
|
3463
|
-
aid: this._aid,
|
|
3464
|
-
deviceId: devId,
|
|
3465
|
-
role: 'self_sync',
|
|
3466
|
-
keySource: String(dev.key_source ?? 'peer_device_prekey'),
|
|
3467
|
-
ikPkDer: ikDer,
|
|
3468
|
-
spkPkDer: spkDer,
|
|
3469
|
-
spkId: String(dev.spk_id ?? ''),
|
|
3470
|
-
});
|
|
3471
|
-
}
|
|
3472
|
-
}
|
|
3473
|
-
catch (exc) {
|
|
3474
|
-
this._clientLog.debug(`V2 self-sync bootstrap failed (non-fatal): ${String(exc)}`);
|
|
3475
|
-
}
|
|
3476
|
-
}
|
|
3477
|
-
const sender = await session.getSenderIdentity();
|
|
3478
|
-
const envelope = await encryptP2PMessage(sender, { targets, auditRecipients: auditTargets }, payload, opts ?? {});
|
|
4383
|
+
const envelope = await this._buildV2P2PEnvelope({
|
|
4384
|
+
to: toAid,
|
|
4385
|
+
payload,
|
|
4386
|
+
messageId: opts?.messageId,
|
|
4387
|
+
timestamp: opts?.timestamp,
|
|
4388
|
+
protectedHeaders: opts?.protectedHeaders,
|
|
4389
|
+
context: opts?.context,
|
|
4390
|
+
useCache,
|
|
4391
|
+
});
|
|
3479
4392
|
return this.call('message.send', {
|
|
3480
4393
|
to: toAid,
|
|
3481
4394
|
payload: envelope,
|
|
@@ -3506,91 +4419,106 @@ export class AUNClient {
|
|
|
3506
4419
|
throw new StateError('V2 session not initialized (not connected?)');
|
|
3507
4420
|
}
|
|
3508
4421
|
const ns = this._aid ? `p2p:${this._aid}` : '';
|
|
3509
|
-
const effective = afterSeq || (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
|
|
3510
|
-
const result = await this.call('message.v2.pull', {
|
|
3511
|
-
after_seq: effective,
|
|
3512
|
-
limit,
|
|
3513
|
-
});
|
|
3514
|
-
const messages = (Array.isArray(result?.messages) ? result.messages : []);
|
|
3515
4422
|
const decrypted = [];
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
this.
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
4423
|
+
let nextAfterSeq = afterSeq || (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
|
|
4424
|
+
let pageCount = 0;
|
|
4425
|
+
const maxPages = 100;
|
|
4426
|
+
while (pageCount < maxPages) {
|
|
4427
|
+
pageCount += 1;
|
|
4428
|
+
const result = await this.call('message.v2.pull', {
|
|
4429
|
+
after_seq: nextAfterSeq,
|
|
4430
|
+
limit,
|
|
4431
|
+
});
|
|
4432
|
+
const messages = (Array.isArray(result?.messages) ? result.messages : []);
|
|
4433
|
+
const seqs = messages
|
|
4434
|
+
.map((msg) => Number(msg.seq ?? 0))
|
|
4435
|
+
.filter((seq) => Number.isFinite(seq) && seq > 0);
|
|
4436
|
+
const pageContigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
|
|
4437
|
+
const pageMaxSeq = seqs.length > 0 ? Math.max(...seqs) : nextAfterSeq;
|
|
4438
|
+
if (ns && seqs.length > 0) {
|
|
4439
|
+
this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
|
|
4440
|
+
}
|
|
4441
|
+
for (const msg of messages) {
|
|
4442
|
+
const seq = Number(msg.seq ?? 0);
|
|
4443
|
+
if (!Number.isFinite(seq) || seq <= 0)
|
|
4444
|
+
continue;
|
|
4445
|
+
const version = String(msg.version ?? 'v2');
|
|
4446
|
+
if (version === 'v1') {
|
|
4447
|
+
const legacy = isJsonObject(msg.legacy_v1) ? msg.legacy_v1 : {};
|
|
4448
|
+
const legacyPayload = legacy.payload;
|
|
4449
|
+
const payloadType = isJsonObject(legacyPayload)
|
|
4450
|
+
? String(legacyPayload.type ?? '').trim()
|
|
4451
|
+
: '';
|
|
4452
|
+
if (legacyPayload !== undefined && legacyPayload !== null
|
|
4453
|
+
&& payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
|
|
4454
|
+
const v1Msg = {
|
|
4455
|
+
message_id: String(msg.message_id ?? ''),
|
|
4456
|
+
from: String(msg.from_aid ?? ''),
|
|
4457
|
+
to: String(legacy.to ?? this._aid ?? ''),
|
|
4458
|
+
seq: msg.seq,
|
|
4459
|
+
type: String(msg.type ?? ''),
|
|
4460
|
+
timestamp: msg.t_server,
|
|
4461
|
+
payload: legacyPayload,
|
|
4462
|
+
encrypted: false,
|
|
4463
|
+
};
|
|
4464
|
+
if (ns)
|
|
4465
|
+
await this._publishPulledMessage('message.received', ns, seq, v1Msg);
|
|
4466
|
+
else
|
|
4467
|
+
await this._publishAppEvent('message.received', v1Msg);
|
|
4468
|
+
decrypted.push(v1Msg);
|
|
4469
|
+
}
|
|
4470
|
+
else {
|
|
4471
|
+
this._clientLog.debug(`message.v2.pull skipping V1 envelope seq=${seq} payload_type=${payloadType || '<none>'} (V1 E2EE removed)`);
|
|
4472
|
+
}
|
|
4473
|
+
continue;
|
|
4474
|
+
}
|
|
4475
|
+
if (version !== 'v2') {
|
|
4476
|
+
this._clientLog.debug(`message.v2.pull skipping non-V2 row seq=${seq} version=${String(msg.version ?? '')}`);
|
|
4477
|
+
continue;
|
|
4478
|
+
}
|
|
4479
|
+
// 跟踪每个旧 SPK 引用的最大 seq(用于消费后销毁)
|
|
4480
|
+
const msgSpkId = String(msg.spk_id ?? '');
|
|
4481
|
+
if (msgSpkId && this._v2Session && !this._v2Session.isCurrentSPK(msgSpkId)) {
|
|
4482
|
+
this._v2Session.trackOldSPKMaxSeq(msgSpkId, seq);
|
|
4483
|
+
}
|
|
4484
|
+
const plaintext = await this._decryptV2Message(msg);
|
|
4485
|
+
if (plaintext === null)
|
|
4486
|
+
continue;
|
|
4487
|
+
if (ns) {
|
|
4488
|
+
await this._publishPulledMessage('message.received', ns, seq, plaintext);
|
|
4489
|
+
decrypted.push(plaintext);
|
|
3551
4490
|
}
|
|
3552
4491
|
else {
|
|
3553
|
-
this.
|
|
4492
|
+
await this._publishAppEvent('message.received', plaintext);
|
|
4493
|
+
decrypted.push(plaintext);
|
|
3554
4494
|
}
|
|
3555
|
-
continue;
|
|
3556
4495
|
}
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
this._v2Session.trackOldSPKMaxSeq(msgSpkId, seq);
|
|
4496
|
+
const serverAckSeq = Number(result.server_ack_seq ?? 0);
|
|
4497
|
+
if (ns && Number.isFinite(serverAckSeq) && serverAckSeq > 0) {
|
|
4498
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
4499
|
+
if (contig < serverAckSeq) {
|
|
4500
|
+
this._clientLog.info(`message.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> server_ack_seq=${serverAckSeq}`);
|
|
4501
|
+
this._seqTracker.forceContiguousSeq(ns, serverAckSeq);
|
|
4502
|
+
}
|
|
3565
4503
|
}
|
|
3566
|
-
// 解密
|
|
3567
|
-
const plaintext = await this._decryptV2Message(msg);
|
|
3568
|
-
if (plaintext === null)
|
|
3569
|
-
continue;
|
|
3570
|
-
// 有序 publish + 去重(与 V1 push 路径对齐)
|
|
3571
4504
|
if (ns) {
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
}
|
|
3580
|
-
if (ns && seqs.length > 0) {
|
|
3581
|
-
const maxSeq = Math.max(...seqs);
|
|
3582
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
3583
|
-
if (maxSeq > contig) {
|
|
3584
|
-
this._seqTracker.forceContiguousSeq(ns, maxSeq);
|
|
3585
|
-
await this._drainOrderedMessages(ns);
|
|
3586
|
-
}
|
|
3587
|
-
const ackSeq = this._seqTracker.getContiguousSeq(ns);
|
|
3588
|
-
if (ackSeq !== contigBefore) {
|
|
3589
|
-
this._saveSeqTrackerState();
|
|
3590
|
-
if (ackSeq > 0) {
|
|
4505
|
+
const ackSeq = this._seqTracker.getContiguousSeq(ns);
|
|
4506
|
+
const contigAdvanced = ackSeq !== pageContigBefore;
|
|
4507
|
+
if (contigAdvanced) {
|
|
4508
|
+
await this._drainOrderedMessages(ns);
|
|
4509
|
+
this._saveSeqTrackerState();
|
|
4510
|
+
}
|
|
4511
|
+
if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
|
|
3591
4512
|
this._safeAsync(this.ackV2(ackSeq).then(() => undefined));
|
|
3592
4513
|
}
|
|
3593
4514
|
}
|
|
4515
|
+
const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
|
|
4516
|
+
if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
|
|
4517
|
+
break;
|
|
4518
|
+
nextAfterSeq = nextAfter;
|
|
4519
|
+
}
|
|
4520
|
+
if (pageCount >= maxPages) {
|
|
4521
|
+
this._clientLog.warn(`message.v2.pull reached max_pages=${maxPages} after_seq=${nextAfterSeq}`);
|
|
3594
4522
|
}
|
|
3595
4523
|
return decrypted;
|
|
3596
4524
|
}
|
|
@@ -3601,9 +4529,17 @@ export class AUNClient {
|
|
|
3601
4529
|
*/
|
|
3602
4530
|
async ackV2(upToSeq) {
|
|
3603
4531
|
const ns = this._aid ? `p2p:${this._aid}` : '';
|
|
3604
|
-
|
|
4532
|
+
let seq = upToSeq ?? (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
|
|
3605
4533
|
if (seq <= 0)
|
|
3606
4534
|
return { acked: 0 };
|
|
4535
|
+
// ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
|
|
4536
|
+
if (ns) {
|
|
4537
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
4538
|
+
if (maxSeen > 0 && seq > maxSeen) {
|
|
4539
|
+
this._clientLog.warn(`ackV2 clamp: up_to_seq=${seq} > max_seen=${maxSeen}, clamp`);
|
|
4540
|
+
seq = maxSeen;
|
|
4541
|
+
}
|
|
4542
|
+
}
|
|
3607
4543
|
const raw = await this.call('message.v2.ack', { up_to_seq: seq });
|
|
3608
4544
|
const result = isJsonObject(raw)
|
|
3609
4545
|
? { ...raw }
|
|
@@ -3634,8 +4570,8 @@ export class AUNClient {
|
|
|
3634
4570
|
}
|
|
3635
4571
|
return result;
|
|
3636
4572
|
}
|
|
3637
|
-
/** 解密单条 V2 消息(与 Python `_decrypt_v2_message`
|
|
3638
|
-
async _decryptV2Message(msg) {
|
|
4573
|
+
/** 解密单条 V2 消息(与 Python `_decrypt_v2_message` 对齐)。缺 sender IK 时先入 pending,后台补齐后重试。 */
|
|
4574
|
+
async _decryptV2Message(msg, allowPending = true) {
|
|
3639
4575
|
const session = this._v2Session;
|
|
3640
4576
|
if (!session)
|
|
3641
4577
|
return null;
|
|
@@ -3650,6 +4586,8 @@ export class AUNClient {
|
|
|
3650
4586
|
this._clientLog.warn(`V2 decrypt: invalid envelope_json for msg seq=${String(msg.seq)}`);
|
|
3651
4587
|
return null;
|
|
3652
4588
|
}
|
|
4589
|
+
const e2eeMeta = v2E2eeMeta(envelope);
|
|
4590
|
+
await this._observeAgentMdFromEnvelope(envelope);
|
|
3653
4591
|
// 确定 spk_id 和 recipient_key_source
|
|
3654
4592
|
let spkId = '';
|
|
3655
4593
|
let recipientKeySource = '';
|
|
@@ -3663,7 +4601,7 @@ export class AUNClient {
|
|
|
3663
4601
|
// 从 recipients 数组中查找本设备的 row 以获取 key_source
|
|
3664
4602
|
if (!spkId) {
|
|
3665
4603
|
for (const row of envelope.recipients) {
|
|
3666
|
-
if (Array.isArray(row) && row.length >= 6 && row[0] === this._aid && row[1] === this._deviceId) {
|
|
4604
|
+
if (Array.isArray(row) && row.length >= 6 && row[0] === this._aid && (row[1] === this._deviceId || row[1] === '')) {
|
|
3667
4605
|
spkId = String(row[5] ?? '');
|
|
3668
4606
|
recipientKeySource = row.length > 3 ? String(row[3] ?? '') : '';
|
|
3669
4607
|
break;
|
|
@@ -3672,40 +4610,80 @@ export class AUNClient {
|
|
|
3672
4610
|
}
|
|
3673
4611
|
else {
|
|
3674
4612
|
for (const row of envelope.recipients) {
|
|
3675
|
-
if (Array.isArray(row) && row.length >= 6 && row[0] === this._aid && row[1] === this._deviceId) {
|
|
4613
|
+
if (Array.isArray(row) && row.length >= 6 && row[0] === this._aid && (row[1] === this._deviceId || row[1] === '')) {
|
|
3676
4614
|
recipientKeySource = row.length > 3 ? String(row[3] ?? '') : '';
|
|
3677
4615
|
break;
|
|
3678
4616
|
}
|
|
3679
4617
|
}
|
|
3680
4618
|
}
|
|
3681
4619
|
}
|
|
3682
|
-
//
|
|
4620
|
+
// group_id 只表示群上下文;getGroupDecryptKeys 内部必须按 group SPK -> P2P device SPK -> IK fallback 查找。
|
|
3683
4621
|
const aad = isJsonObject(envelope.aad) ? envelope.aad : {};
|
|
3684
4622
|
const groupIdForKeys = String(msg.group_id ?? aad.group_id ?? envelope.group_id ?? '').trim();
|
|
4623
|
+
const undecryptableEvent = groupIdForKeys ? 'group.message_undecryptable' : 'message.undecryptable';
|
|
3685
4624
|
let ikPriv;
|
|
3686
4625
|
let spkPriv;
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
4626
|
+
try {
|
|
4627
|
+
if (groupIdForKeys) {
|
|
4628
|
+
const keys = await session.getGroupDecryptKeys(groupIdForKeys, spkId);
|
|
4629
|
+
ikPriv = keys.ikPriv;
|
|
4630
|
+
spkPriv = keys.spkPriv;
|
|
4631
|
+
}
|
|
4632
|
+
else {
|
|
4633
|
+
const keys = await session.getDecryptKeys(spkId);
|
|
4634
|
+
ikPriv = keys.ikPriv;
|
|
4635
|
+
spkPriv = keys.spkPriv;
|
|
4636
|
+
}
|
|
3691
4637
|
}
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
4638
|
+
catch (exc) {
|
|
4639
|
+
this._clientLog.warn(`V2 decrypt: SPK lookup failed seq=${String(msg.seq)} spk_id=${spkId}: ${String(exc)}`);
|
|
4640
|
+
try {
|
|
4641
|
+
const event = {
|
|
4642
|
+
message_id: String(msg.message_id ?? ''),
|
|
4643
|
+
from: String(msg.from_aid ?? ''),
|
|
4644
|
+
to: String(msg.to ?? ''),
|
|
4645
|
+
seq: msg.seq,
|
|
4646
|
+
timestamp: (msg.t_server ?? msg.timestamp),
|
|
4647
|
+
device_id: String(msg.device_id ?? ''),
|
|
4648
|
+
slot_id: String(msg.slot_id ?? ''),
|
|
4649
|
+
_decrypt_error: String(exc),
|
|
4650
|
+
_decrypt_stage: 'spk_lookup',
|
|
4651
|
+
_envelope_type: String(envelope.type ?? ''),
|
|
4652
|
+
_suite: String(envelope.suite ?? ''),
|
|
4653
|
+
_spk_id: spkId,
|
|
4654
|
+
};
|
|
4655
|
+
attachV2EnvelopeMetadata(event, e2eeMeta);
|
|
4656
|
+
await this._dispatcher.publish(undecryptableEvent, event);
|
|
4657
|
+
}
|
|
4658
|
+
catch { /* publish 异常不影响主流程 */ }
|
|
4659
|
+
return null;
|
|
3696
4660
|
}
|
|
3697
4661
|
const fromAid = String(msg.from_aid ?? '');
|
|
3698
4662
|
const senderDeviceId = String(aad.from_device ?? '');
|
|
3699
4663
|
const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId);
|
|
3700
4664
|
if (!senderPubDer) {
|
|
3701
|
-
this._clientLog.warn(`V2 decrypt: no sender IK for ${fromAid}
|
|
4665
|
+
this._clientLog.warn(`V2 decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
|
|
4666
|
+
if (allowPending) {
|
|
4667
|
+
this._scheduleV2SenderIKPending({ msg, fromAid, senderDeviceId, groupId: groupIdForKeys });
|
|
4668
|
+
return null;
|
|
4669
|
+
}
|
|
3702
4670
|
try {
|
|
3703
|
-
|
|
4671
|
+
const event = {
|
|
3704
4672
|
message_id: String(msg.message_id ?? ''),
|
|
3705
4673
|
from: fromAid,
|
|
4674
|
+
to: String(msg.to ?? ''),
|
|
3706
4675
|
seq: msg.seq,
|
|
4676
|
+
timestamp: (msg.t_server ?? msg.timestamp),
|
|
4677
|
+
device_id: String(msg.device_id ?? ''),
|
|
4678
|
+
slot_id: String(msg.slot_id ?? ''),
|
|
3707
4679
|
_decrypt_error: 'sender_ik_not_found',
|
|
3708
|
-
|
|
4680
|
+
_decrypt_stage: 'sender_ik',
|
|
4681
|
+
_envelope_type: String(envelope.type ?? ''),
|
|
4682
|
+
_suite: String(envelope.suite ?? ''),
|
|
4683
|
+
_sender_device_id: String(aad.from_device ?? ''),
|
|
4684
|
+
};
|
|
4685
|
+
attachV2EnvelopeMetadata(event, e2eeMeta);
|
|
4686
|
+
await this._dispatcher.publish(undecryptableEvent, event);
|
|
3709
4687
|
}
|
|
3710
4688
|
catch { /* publish 异常不影响主流程 */ }
|
|
3711
4689
|
return null;
|
|
@@ -3717,12 +4695,22 @@ export class AUNClient {
|
|
|
3717
4695
|
catch (exc) {
|
|
3718
4696
|
this._clientLog.warn(`V2 decrypt failed for msg seq=${String(msg.seq)}: ${String(exc)}`);
|
|
3719
4697
|
try {
|
|
3720
|
-
|
|
4698
|
+
const event = {
|
|
3721
4699
|
message_id: String(msg.message_id ?? ''),
|
|
3722
4700
|
from: fromAid,
|
|
4701
|
+
to: String(msg.to ?? ''),
|
|
3723
4702
|
seq: msg.seq,
|
|
4703
|
+
timestamp: (msg.t_server ?? msg.timestamp),
|
|
4704
|
+
device_id: String(msg.device_id ?? ''),
|
|
4705
|
+
slot_id: String(msg.slot_id ?? ''),
|
|
3724
4706
|
_decrypt_error: String(exc),
|
|
3725
|
-
|
|
4707
|
+
_decrypt_stage: 'decrypt',
|
|
4708
|
+
_envelope_type: String(envelope.type ?? ''),
|
|
4709
|
+
_suite: String(envelope.suite ?? ''),
|
|
4710
|
+
_sender_device_id: String(aad.from_device ?? ''),
|
|
4711
|
+
};
|
|
4712
|
+
attachV2EnvelopeMetadata(event, e2eeMeta);
|
|
4713
|
+
await this._dispatcher.publish(undecryptableEvent, event);
|
|
3726
4714
|
}
|
|
3727
4715
|
catch { /* publish 异常不影响主流程 */ }
|
|
3728
4716
|
return null;
|
|
@@ -3748,7 +4736,8 @@ export class AUNClient {
|
|
|
3748
4736
|
this._clientLog.debug(`V2 SPK rotation failed (non-fatal): ${exc}`);
|
|
3749
4737
|
});
|
|
3750
4738
|
}
|
|
3751
|
-
|
|
4739
|
+
const e2ee = v2E2eeMeta(envelope);
|
|
4740
|
+
const result = {
|
|
3752
4741
|
message_id: String(msg.message_id ?? ''),
|
|
3753
4742
|
from: fromAid,
|
|
3754
4743
|
to: this._aid ?? '',
|
|
@@ -3756,8 +4745,10 @@ export class AUNClient {
|
|
|
3756
4745
|
t_server: msg.t_server,
|
|
3757
4746
|
payload: plaintext,
|
|
3758
4747
|
encrypted: true,
|
|
3759
|
-
e2ee:
|
|
4748
|
+
e2ee: e2ee,
|
|
3760
4749
|
};
|
|
4750
|
+
attachV2EnvelopeMetadata(result, e2ee);
|
|
4751
|
+
return result;
|
|
3761
4752
|
}
|
|
3762
4753
|
/**
|
|
3763
4754
|
* V2 Group 加密发送(推测性:用缓存 bootstrap 直接发,失败刷新重试一次)。
|
|
@@ -3843,32 +4834,53 @@ export class AUNClient {
|
|
|
3843
4834
|
if (!gid)
|
|
3844
4835
|
throw new ValidationError('group.pull requires group_id');
|
|
3845
4836
|
const ns = `group:${gid}`;
|
|
3846
|
-
const effective = afterSeq || this._seqTracker.getContiguousSeq(ns);
|
|
3847
|
-
const result = await this.call('group.v2.pull', {
|
|
3848
|
-
group_id: gid,
|
|
3849
|
-
after_seq: effective,
|
|
3850
|
-
limit,
|
|
3851
|
-
});
|
|
3852
|
-
const messages = (Array.isArray(result?.messages) ? result.messages : []);
|
|
3853
4837
|
const decrypted = [];
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
this.
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
const
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
4838
|
+
let nextAfterSeq = afterSeq || this._seqTracker.getContiguousSeq(ns);
|
|
4839
|
+
let pageCount = 0;
|
|
4840
|
+
const maxPages = 100;
|
|
4841
|
+
while (pageCount < maxPages) {
|
|
4842
|
+
pageCount += 1;
|
|
4843
|
+
const result = await this.call('group.v2.pull', {
|
|
4844
|
+
group_id: gid,
|
|
4845
|
+
after_seq: nextAfterSeq,
|
|
4846
|
+
limit,
|
|
4847
|
+
});
|
|
4848
|
+
const messages = (Array.isArray(result?.messages) ? result.messages : []);
|
|
4849
|
+
const seqs = messages
|
|
4850
|
+
.map((msg) => Number(msg.seq ?? 0))
|
|
4851
|
+
.filter((seq) => Number.isFinite(seq) && seq > 0);
|
|
4852
|
+
const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
4853
|
+
const pageMaxSeq = seqs.length > 0 ? Math.max(...seqs) : nextAfterSeq;
|
|
4854
|
+
if (seqs.length > 0) {
|
|
4855
|
+
this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
|
|
4856
|
+
}
|
|
4857
|
+
for (const msg of messages) {
|
|
4858
|
+
const seq = Number(msg.seq ?? 0);
|
|
4859
|
+
if (!Number.isFinite(seq) || seq <= 0)
|
|
4860
|
+
continue;
|
|
4861
|
+
const version = String(msg.version ?? 'v2');
|
|
4862
|
+
if (version === 'v1') {
|
|
4863
|
+
const payload = msg.payload;
|
|
4864
|
+
const payloadObj = isJsonObject(payload) ? payload : null;
|
|
4865
|
+
if (payloadObj) {
|
|
4866
|
+
const payloadType = String(payloadObj.type ?? '').trim();
|
|
4867
|
+
if (payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
|
|
4868
|
+
const v1Msg = {
|
|
4869
|
+
message_id: String(msg.message_id ?? ''),
|
|
4870
|
+
from: String(msg.from_aid ?? ''),
|
|
4871
|
+
group_id: gid,
|
|
4872
|
+
seq: msg.seq,
|
|
4873
|
+
type: String(msg.type ?? ''),
|
|
4874
|
+
timestamp: msg.t_server,
|
|
4875
|
+
payload,
|
|
4876
|
+
encrypted: false,
|
|
4877
|
+
};
|
|
4878
|
+
await this._publishPulledMessage('group.message_created', ns, seq, v1Msg);
|
|
4879
|
+
decrypted.push(v1Msg);
|
|
4880
|
+
continue;
|
|
4881
|
+
}
|
|
4882
|
+
}
|
|
4883
|
+
else if (payload !== undefined && payload !== null) {
|
|
3872
4884
|
const v1Msg = {
|
|
3873
4885
|
message_id: String(msg.message_id ?? ''),
|
|
3874
4886
|
from: String(msg.from_aid ?? ''),
|
|
@@ -3883,51 +4895,45 @@ export class AUNClient {
|
|
|
3883
4895
|
decrypted.push(v1Msg);
|
|
3884
4896
|
continue;
|
|
3885
4897
|
}
|
|
4898
|
+
this._clientLog.debug(`group.v2.pull skipping V1 envelope group=${gid} seq=${seq} payload_type=${payloadObj ? String(payloadObj.type ?? '') : '<none>'} (V1 E2EE removed)`);
|
|
4899
|
+
continue;
|
|
3886
4900
|
}
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
message_id: String(msg.message_id ?? ''),
|
|
3890
|
-
from: String(msg.from_aid ?? ''),
|
|
3891
|
-
group_id: gid,
|
|
3892
|
-
seq: msg.seq,
|
|
3893
|
-
type: String(msg.type ?? ''),
|
|
3894
|
-
timestamp: msg.t_server,
|
|
3895
|
-
payload,
|
|
3896
|
-
encrypted: false,
|
|
3897
|
-
};
|
|
3898
|
-
await this._publishPulledMessage('group.message_created', ns, seq, v1Msg);
|
|
3899
|
-
decrypted.push(v1Msg);
|
|
4901
|
+
if (version !== 'v2') {
|
|
4902
|
+
this._clientLog.debug(`group.v2.pull skipping non-V2 row group=${gid} seq=${seq} version=${String(msg.version ?? '')}`);
|
|
3900
4903
|
continue;
|
|
3901
4904
|
}
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
this.
|
|
3907
|
-
|
|
4905
|
+
const plaintext = await this._decryptV2Message(msg);
|
|
4906
|
+
if (plaintext === null)
|
|
4907
|
+
continue;
|
|
4908
|
+
plaintext.group_id = gid;
|
|
4909
|
+
await this._publishPulledMessage('group.message_created', ns, seq, plaintext);
|
|
4910
|
+
decrypted.push(plaintext);
|
|
3908
4911
|
}
|
|
3909
|
-
const
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
if (seqs.length > 0) {
|
|
3918
|
-
const maxSeq = Math.max(...seqs);
|
|
3919
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
3920
|
-
if (maxSeq > contig) {
|
|
3921
|
-
this._seqTracker.forceContiguousSeq(ns, maxSeq);
|
|
3922
|
-
await this._drainOrderedMessages(ns);
|
|
4912
|
+
const cursor = isJsonObject(result.cursor) ? result.cursor : null;
|
|
4913
|
+
const serverAckSeq = Number(cursor?.current_seq ?? 0);
|
|
4914
|
+
if (Number.isFinite(serverAckSeq) && serverAckSeq > 0) {
|
|
4915
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
4916
|
+
if (contig < serverAckSeq) {
|
|
4917
|
+
this._clientLog.info(`group.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> cursor.current_seq=${serverAckSeq}`);
|
|
4918
|
+
this._seqTracker.forceContiguousSeq(ns, serverAckSeq);
|
|
4919
|
+
}
|
|
3923
4920
|
}
|
|
3924
4921
|
const ackSeq = this._seqTracker.getContiguousSeq(ns);
|
|
3925
|
-
|
|
4922
|
+
const contigAdvanced = ackSeq !== pageContigBefore;
|
|
4923
|
+
if (contigAdvanced) {
|
|
4924
|
+
await this._drainOrderedMessages(ns);
|
|
3926
4925
|
this._saveSeqTrackerState();
|
|
3927
|
-
if (ackSeq > 0) {
|
|
3928
|
-
this._safeAsync(this.ackGroupV2(gid, ackSeq).then(() => undefined));
|
|
3929
|
-
}
|
|
3930
4926
|
}
|
|
4927
|
+
if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
|
|
4928
|
+
this._safeAsync(this.ackGroupV2(gid, ackSeq).then(() => undefined));
|
|
4929
|
+
}
|
|
4930
|
+
const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
|
|
4931
|
+
if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
|
|
4932
|
+
break;
|
|
4933
|
+
nextAfterSeq = nextAfter;
|
|
4934
|
+
}
|
|
4935
|
+
if (pageCount >= maxPages) {
|
|
4936
|
+
this._clientLog.warn(`group.v2.pull reached max_pages=${maxPages} group=${gid} after_seq=${nextAfterSeq}`);
|
|
3931
4937
|
}
|
|
3932
4938
|
return decrypted;
|
|
3933
4939
|
}
|
|
@@ -3942,9 +4948,15 @@ export class AUNClient {
|
|
|
3942
4948
|
if (!gid)
|
|
3943
4949
|
throw new ValidationError('group.ack_messages requires group_id');
|
|
3944
4950
|
const ns = `group:${gid}`;
|
|
3945
|
-
|
|
4951
|
+
let seq = upToSeq ?? this._seqTracker.getContiguousSeq(ns);
|
|
3946
4952
|
if (seq <= 0)
|
|
3947
4953
|
return { acked: 0 };
|
|
4954
|
+
// ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
|
|
4955
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
4956
|
+
if (maxSeen > 0 && seq > maxSeen) {
|
|
4957
|
+
this._clientLog.warn(`ackGroupV2 clamp: group=${gid} up_to_seq=${seq} > max_seen=${maxSeen}, clamp`);
|
|
4958
|
+
seq = maxSeen;
|
|
4959
|
+
}
|
|
3948
4960
|
return this.call('group.v2.ack', { group_id: gid, up_to_seq: seq });
|
|
3949
4961
|
}
|
|
3950
4962
|
// ── V2 thought(per-device wrap,服务端透传,不持久化)──────────
|
|
@@ -3962,20 +4974,27 @@ export class AUNClient {
|
|
|
3962
4974
|
const useCache = opts.useCache !== false;
|
|
3963
4975
|
let peerDevices = [];
|
|
3964
4976
|
let auditRaw = [];
|
|
4977
|
+
let wrapPolicy;
|
|
3965
4978
|
const cached = useCache ? this._v2BootstrapCache.get(to) : undefined;
|
|
3966
4979
|
if (cached && (Date.now() - cached.cachedAt) < AUNClient.V2_BOOTSTRAP_TTL_MS) {
|
|
3967
4980
|
peerDevices = cached.devices;
|
|
3968
4981
|
auditRaw = cached.auditRecipients;
|
|
4982
|
+
wrapPolicy = cached.wrapPolicy;
|
|
3969
4983
|
}
|
|
3970
4984
|
else {
|
|
3971
|
-
const bs = await this.call('message.v2.bootstrap', {
|
|
4985
|
+
const bs = await this.call('message.v2.bootstrap', {
|
|
4986
|
+
peer_aid: to,
|
|
4987
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
4988
|
+
});
|
|
3972
4989
|
peerDevices = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
|
|
3973
4990
|
auditRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
|
|
4991
|
+
wrapPolicy = normalizeV2WrapPolicy(bs?.e2ee_wrap_policy);
|
|
3974
4992
|
if (peerDevices.length > 0) {
|
|
3975
4993
|
this._v2BootstrapCache.set(to, {
|
|
3976
4994
|
devices: peerDevices,
|
|
3977
4995
|
auditRecipients: auditRaw,
|
|
3978
4996
|
cachedAt: Date.now(),
|
|
4997
|
+
wrapPolicy,
|
|
3979
4998
|
});
|
|
3980
4999
|
}
|
|
3981
5000
|
}
|
|
@@ -3984,33 +5003,27 @@ export class AUNClient {
|
|
|
3984
5003
|
}
|
|
3985
5004
|
const targets = [];
|
|
3986
5005
|
for (const dev of peerDevices) {
|
|
3987
|
-
const
|
|
3988
|
-
const
|
|
3989
|
-
|
|
3990
|
-
targets.push({
|
|
5006
|
+
const devId = getV2DeviceId(dev);
|
|
5007
|
+
const target = await this._v2BuildTargetFromDevice({
|
|
5008
|
+
dev,
|
|
3991
5009
|
aid: to,
|
|
3992
|
-
deviceId:
|
|
5010
|
+
deviceId: devId.value,
|
|
3993
5011
|
role: 'peer',
|
|
3994
|
-
|
|
3995
|
-
ikPkDer: ikDer,
|
|
3996
|
-
spkPkDer: spkDer,
|
|
3997
|
-
spkId: String(dev.spk_id ?? ''),
|
|
5012
|
+
defaultKeySource: 'peer_device_prekey',
|
|
3998
5013
|
});
|
|
5014
|
+
if (target)
|
|
5015
|
+
targets.push(target);
|
|
3999
5016
|
}
|
|
4000
5017
|
for (const dev of auditRaw) {
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
const ikDer = _v2B64ToBytes(String(dev.ik_pk));
|
|
4004
|
-
const spkDer = dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined;
|
|
4005
|
-
targets.push({
|
|
5018
|
+
const target = await this._v2BuildTargetFromDevice({
|
|
5019
|
+
dev,
|
|
4006
5020
|
aid: String(dev.aid ?? ''),
|
|
4007
5021
|
deviceId: String(dev.device_id ?? ''),
|
|
4008
5022
|
role: 'audit',
|
|
4009
|
-
|
|
4010
|
-
ikPkDer: ikDer,
|
|
4011
|
-
spkPkDer: spkDer,
|
|
4012
|
-
spkId: String(dev.spk_id ?? ''),
|
|
5023
|
+
defaultKeySource: 'peer_device_prekey',
|
|
4013
5024
|
});
|
|
5025
|
+
if (target)
|
|
5026
|
+
targets.push(target);
|
|
4014
5027
|
}
|
|
4015
5028
|
// self-sync:自己其它设备
|
|
4016
5029
|
if (this._aid && this._aid !== to) {
|
|
@@ -4021,7 +5034,10 @@ export class AUNClient {
|
|
|
4021
5034
|
selfDevices = selfCached.devices;
|
|
4022
5035
|
}
|
|
4023
5036
|
else {
|
|
4024
|
-
const selfBs = await this.call('message.v2.bootstrap', {
|
|
5037
|
+
const selfBs = await this.call('message.v2.bootstrap', {
|
|
5038
|
+
peer_aid: this._aid,
|
|
5039
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
5040
|
+
});
|
|
4025
5041
|
selfDevices = (Array.isArray(selfBs?.peer_devices) ? selfBs.peer_devices : []);
|
|
4026
5042
|
if (selfDevices.length > 0) {
|
|
4027
5043
|
this._v2BootstrapCache.set(this._aid, {
|
|
@@ -4032,20 +5048,18 @@ export class AUNClient {
|
|
|
4032
5048
|
}
|
|
4033
5049
|
}
|
|
4034
5050
|
for (const dev of selfDevices) {
|
|
4035
|
-
const devId =
|
|
4036
|
-
if (devId === this._deviceId)
|
|
5051
|
+
const devId = getV2DeviceId(dev);
|
|
5052
|
+
if (!devId.present || devId.value === this._deviceId)
|
|
4037
5053
|
continue;
|
|
4038
|
-
const
|
|
4039
|
-
|
|
4040
|
-
targets.push({
|
|
5054
|
+
const target = await this._v2BuildTargetFromDevice({
|
|
5055
|
+
dev,
|
|
4041
5056
|
aid: this._aid,
|
|
4042
|
-
deviceId: devId,
|
|
5057
|
+
deviceId: devId.value,
|
|
4043
5058
|
role: 'self_sync',
|
|
4044
|
-
|
|
4045
|
-
ikPkDer: ikDer,
|
|
4046
|
-
spkPkDer: spkDer,
|
|
4047
|
-
spkId: String(dev.spk_id ?? ''),
|
|
5059
|
+
defaultKeySource: 'peer_device_prekey',
|
|
4048
5060
|
});
|
|
5061
|
+
if (target)
|
|
5062
|
+
targets.push(target);
|
|
4049
5063
|
}
|
|
4050
5064
|
}
|
|
4051
5065
|
catch (exc) {
|
|
@@ -4053,7 +5067,8 @@ export class AUNClient {
|
|
|
4053
5067
|
}
|
|
4054
5068
|
}
|
|
4055
5069
|
const sender = await session.getSenderIdentity();
|
|
4056
|
-
const
|
|
5070
|
+
const sendTargets = applyV2WrapPolicyToTargets(targets, wrapPolicy);
|
|
5071
|
+
const envelope = await encryptP2PMessage(sender, { targets: sendTargets, auditRecipients: [] }, opts.payload, { messageId: opts.messageId, timestamp: opts.timestamp, protectedHeaders: opts.protectedHeaders, context: opts.context });
|
|
4057
5072
|
return envelope;
|
|
4058
5073
|
}
|
|
4059
5074
|
/**
|
|
@@ -4127,18 +5142,27 @@ export class AUNClient {
|
|
|
4127
5142
|
let epoch = 0;
|
|
4128
5143
|
let stateCommitment = { state_version: 0, state_hash: '', state_chain: '' };
|
|
4129
5144
|
let auditRecipientsRaw = [];
|
|
5145
|
+
let wrapPolicy;
|
|
4130
5146
|
const cached = useCache ? this._v2BootstrapCache.get(cacheKey) : undefined;
|
|
4131
5147
|
if (cached && (Date.now() - cached.cachedAt) < AUNClient.V2_BOOTSTRAP_TTL_MS) {
|
|
4132
5148
|
allDevices = cached.devices;
|
|
4133
5149
|
epoch = cached.epoch ?? 0;
|
|
4134
5150
|
stateCommitment = cached.stateCommitment ?? { state_version: 0, state_hash: '', state_chain: '' };
|
|
4135
5151
|
auditRecipientsRaw = cached.auditRecipients;
|
|
5152
|
+
wrapPolicy = cached.wrapPolicy;
|
|
4136
5153
|
}
|
|
4137
5154
|
else {
|
|
4138
|
-
const bs = await this.call('group.v2.bootstrap', {
|
|
5155
|
+
const bs = await this.call('group.v2.bootstrap', {
|
|
5156
|
+
group_id: groupId,
|
|
5157
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
5158
|
+
});
|
|
4139
5159
|
allDevices = (Array.isArray(bs?.devices) ? bs.devices : []);
|
|
4140
5160
|
epoch = Number(bs?.epoch ?? 0);
|
|
4141
5161
|
auditRecipientsRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
|
|
5162
|
+
wrapPolicy = normalizeV2WrapPolicy(bs?.e2ee_wrap_policy);
|
|
5163
|
+
await this._v2CheckFork(groupId, String(bs?.state_chain ?? ''));
|
|
5164
|
+
await this._v2VerifyStateSignature(groupId, bs);
|
|
5165
|
+
await this._publishV2GroupSecurityLevel(groupId, bs);
|
|
4142
5166
|
stateCommitment = {
|
|
4143
5167
|
state_version: Number(bs?.state_version ?? 0) || 0,
|
|
4144
5168
|
state_hash: String(bs?.state_hash_signed ?? bs?.state_hash ?? ''),
|
|
@@ -4151,11 +5175,9 @@ export class AUNClient {
|
|
|
4151
5175
|
cachedAt: Date.now(),
|
|
4152
5176
|
epoch,
|
|
4153
5177
|
stateCommitment: stateCommitment,
|
|
5178
|
+
wrapPolicy,
|
|
4154
5179
|
});
|
|
4155
5180
|
}
|
|
4156
|
-
await this._v2CheckFork(groupId, String(bs?.state_chain ?? ''));
|
|
4157
|
-
await this._v2VerifyStateSignature(groupId, bs);
|
|
4158
|
-
await this._publishV2GroupSecurityLevel(groupId, bs);
|
|
4159
5181
|
// lazy sync 触发:发现 pending members 时异步发起提案
|
|
4160
5182
|
const pendingAdds = Array.isArray(bs?.pending_adds) ? bs.pending_adds : [];
|
|
4161
5183
|
if (pendingAdds.length > 0 && this._v2Session) {
|
|
@@ -4168,42 +5190,37 @@ export class AUNClient {
|
|
|
4168
5190
|
const targets = [];
|
|
4169
5191
|
for (const dev of allDevices) {
|
|
4170
5192
|
const devAid = String(dev.aid ?? '');
|
|
4171
|
-
const devId =
|
|
4172
|
-
if (devAid === this._aid && devId === this._deviceId)
|
|
5193
|
+
const devId = getV2DeviceId(dev);
|
|
5194
|
+
if (devAid === this._aid && devId.present && devId.value === this._deviceId)
|
|
4173
5195
|
continue;
|
|
4174
|
-
const ikDer = _v2B64ToBytes(String(dev.ik_pk ?? ''));
|
|
4175
|
-
const spkDer = dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined;
|
|
4176
5196
|
const role = devAid === this._aid ? 'self_sync' : 'member';
|
|
4177
|
-
|
|
5197
|
+
const target = await this._v2BuildTargetFromDevice({
|
|
5198
|
+
dev,
|
|
4178
5199
|
aid: devAid,
|
|
4179
|
-
deviceId: devId,
|
|
5200
|
+
deviceId: devId.value,
|
|
4180
5201
|
role,
|
|
4181
|
-
|
|
4182
|
-
ikPkDer: ikDer,
|
|
4183
|
-
spkPkDer: spkDer,
|
|
4184
|
-
spkId: String(dev.spk_id ?? ''),
|
|
5202
|
+
defaultKeySource: 'peer_device_prekey',
|
|
4185
5203
|
});
|
|
5204
|
+
if (target)
|
|
5205
|
+
targets.push(target);
|
|
4186
5206
|
}
|
|
4187
5207
|
for (const dev of auditRecipientsRaw) {
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
const ikDer = _v2B64ToBytes(String(dev.ik_pk));
|
|
4191
|
-
const spkDer = dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined;
|
|
4192
|
-
targets.push({
|
|
5208
|
+
const target = await this._v2BuildTargetFromDevice({
|
|
5209
|
+
dev,
|
|
4193
5210
|
aid: String(dev.aid ?? ''),
|
|
4194
5211
|
deviceId: String(dev.device_id ?? ''),
|
|
4195
5212
|
role: 'audit',
|
|
4196
|
-
|
|
4197
|
-
ikPkDer: ikDer,
|
|
4198
|
-
spkPkDer: spkDer,
|
|
4199
|
-
spkId: String(dev.spk_id ?? ''),
|
|
5213
|
+
defaultKeySource: 'peer_device_prekey',
|
|
4200
5214
|
});
|
|
5215
|
+
if (target)
|
|
5216
|
+
targets.push(target);
|
|
4201
5217
|
}
|
|
4202
5218
|
if (targets.length === 0) {
|
|
4203
5219
|
throw new E2EEError(`V2 group: no target devices for ${groupId}`);
|
|
4204
5220
|
}
|
|
4205
5221
|
const sender = await session.getSenderIdentity();
|
|
4206
|
-
const
|
|
5222
|
+
const sendTargets = applyV2WrapPolicyToTargets(targets, wrapPolicy);
|
|
5223
|
+
const envelope = await encryptGroupMessage(sender, groupId, epoch, sendTargets, opts.payload, { messageId: opts.messageId, timestamp: opts.timestamp, protectedHeaders: opts.protectedHeaders, context: opts.context }, stateCommitment);
|
|
4207
5224
|
return envelope;
|
|
4208
5225
|
}
|
|
4209
5226
|
async _publishV2GroupSecurityLevel(groupId, bootstrap) {
|
|
@@ -4286,7 +5303,7 @@ export class AUNClient {
|
|
|
4286
5303
|
if (Array.isArray(recipients)) {
|
|
4287
5304
|
for (const row of recipients) {
|
|
4288
5305
|
if (Array.isArray(row) && row.length >= 6) {
|
|
4289
|
-
if (row[0] === this._aid && row[1] === this._deviceId) {
|
|
5306
|
+
if (row[0] === this._aid && (row[1] === this._deviceId || row[1] === '')) {
|
|
4290
5307
|
spkId = String(row[5] ?? '');
|
|
4291
5308
|
recipientKeySource = row.length > 3 ? String(row[3] ?? '') : '';
|
|
4292
5309
|
break;
|
|
@@ -4294,26 +5311,33 @@ export class AUNClient {
|
|
|
4294
5311
|
}
|
|
4295
5312
|
}
|
|
4296
5313
|
}
|
|
4297
|
-
// 根据 aad.group_id 判断使用 group SPK 还是 P2P SPK
|
|
4298
5314
|
const aad = opts.envelope.aad ?? {};
|
|
4299
5315
|
const groupIdForKeys = String(aad.group_id ?? opts.envelope.group_id ?? '').trim();
|
|
4300
5316
|
let ikPriv;
|
|
4301
5317
|
let spkPriv;
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
5318
|
+
// group_id 只表示群上下文;group lookup 内部按 group SPK -> P2P device SPK -> IK fallback。
|
|
5319
|
+
try {
|
|
5320
|
+
if (groupIdForKeys) {
|
|
5321
|
+
const keys = await session.getGroupDecryptKeys(groupIdForKeys, spkId);
|
|
5322
|
+
ikPriv = keys.ikPriv;
|
|
5323
|
+
spkPriv = keys.spkPriv;
|
|
5324
|
+
}
|
|
5325
|
+
else {
|
|
5326
|
+
const keys = await session.getDecryptKeys(spkId);
|
|
5327
|
+
ikPriv = keys.ikPriv;
|
|
5328
|
+
spkPriv = keys.spkPriv;
|
|
5329
|
+
}
|
|
4306
5330
|
}
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
spkPriv = keys.spkPriv;
|
|
5331
|
+
catch (exc) {
|
|
5332
|
+
this._clientLog.warn(`V2 thought decrypt: SPK lookup failed from=${opts.fromAid}, group=${groupIdForKeys || '<p2p>'}, spk_id=${spkId || '<empty>'}: ${exc}`);
|
|
5333
|
+
return null;
|
|
4311
5334
|
}
|
|
4312
5335
|
const fromAid = String(opts.fromAid || aad.from || '').trim();
|
|
4313
5336
|
const senderDeviceId = String(aad.from_device ?? '');
|
|
4314
5337
|
const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId);
|
|
4315
5338
|
if (!senderPubDer) {
|
|
4316
5339
|
this._clientLog.warn(`V2 thought decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
|
|
5340
|
+
this._scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupIdForKeys);
|
|
4317
5341
|
return null;
|
|
4318
5342
|
}
|
|
4319
5343
|
try {
|
|
@@ -4367,11 +5391,8 @@ export class AUNClient {
|
|
|
4367
5391
|
const signPayloadBytes = new TextEncoder().encode(signPayload);
|
|
4368
5392
|
const sigBytes = base64ToUint8(stateSignature);
|
|
4369
5393
|
// 验签缓存检查
|
|
4370
|
-
const
|
|
4371
|
-
const
|
|
4372
|
-
cacheData.set(cacheInput, 0);
|
|
4373
|
-
cacheData.set(sigBytes, cacheInput.length);
|
|
4374
|
-
const cacheHashBuf = await crypto.subtle.digest('SHA-256', cacheData.buffer);
|
|
5394
|
+
const cacheData = _v2LengthPrefixedBytes(new TextEncoder().encode(actorAid), signPayloadBytes, sigBytes);
|
|
5395
|
+
const cacheHashBuf = await crypto.subtle.digest('SHA-256', cacheData.slice().buffer);
|
|
4375
5396
|
const cacheHashArr = new Uint8Array(cacheHashBuf);
|
|
4376
5397
|
let cacheKey = '';
|
|
4377
5398
|
for (let i = 0; i < cacheHashArr.length; i++)
|
|
@@ -4594,13 +5615,17 @@ export class AUNClient {
|
|
|
4594
5615
|
}
|
|
4595
5616
|
if (myRole !== 'owner' && myRole !== 'admin')
|
|
4596
5617
|
return false;
|
|
4597
|
-
const bootstrapResp = await this.call('group.v2.bootstrap', {
|
|
5618
|
+
const bootstrapResp = await this.call('group.v2.bootstrap', {
|
|
5619
|
+
group_id: groupId,
|
|
5620
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
5621
|
+
});
|
|
4598
5622
|
const devices = (Array.isArray(bootstrapResp?.devices) ? bootstrapResp.devices : []);
|
|
4599
5623
|
const candidates = [];
|
|
4600
5624
|
for (const dev of devices) {
|
|
4601
5625
|
const aid = String(dev.aid ?? '').trim();
|
|
5626
|
+
const hasDeviceId = 'device_id' in dev;
|
|
4602
5627
|
const deviceId = String(dev.device_id ?? '').trim();
|
|
4603
|
-
if (aid &&
|
|
5628
|
+
if (aid && hasDeviceId && onlineAdminAids.has(aid)) {
|
|
4604
5629
|
candidates.push(`${aid}\x1f${deviceId}`);
|
|
4605
5630
|
}
|
|
4606
5631
|
}
|
|
@@ -4616,7 +5641,7 @@ export class AUNClient {
|
|
|
4616
5641
|
this._clientLog.debug(`V2 auto propose leader elected: group=${groupId} leader=${leader}`);
|
|
4617
5642
|
return true;
|
|
4618
5643
|
}
|
|
4619
|
-
const delayMs = await this._v2LeaderDelayMs(
|
|
5644
|
+
const delayMs = await this._v2LeaderDelayMs(_v2LengthPrefixedTextKey(groupId, myKey));
|
|
4620
5645
|
this._clientLog.debug(`V2 auto propose non-leader delay: group=${groupId} leader=${leader} self=${myKey} delay_ms=${delayMs}`);
|
|
4621
5646
|
await this._sleep(delayMs);
|
|
4622
5647
|
return true;
|
|
@@ -4695,7 +5720,10 @@ export class AUNClient {
|
|
|
4695
5720
|
}
|
|
4696
5721
|
}
|
|
4697
5722
|
// 获取群所有成员的设备列表(V2 bootstrap)
|
|
4698
|
-
const bootstrapResp = await this.call('group.v2.bootstrap', {
|
|
5723
|
+
const bootstrapResp = await this.call('group.v2.bootstrap', {
|
|
5724
|
+
group_id: groupId,
|
|
5725
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
5726
|
+
});
|
|
4699
5727
|
const allDevices = (Array.isArray(bootstrapResp?.devices) ? bootstrapResp.devices : []);
|
|
4700
5728
|
const auditRecipients = (Array.isArray(bootstrapResp?.audit_recipients) ? bootstrapResp.audit_recipients : []);
|
|
4701
5729
|
const auditAidsList = [...new Set(auditRecipients.map(r => String(r.aid ?? '').trim()).filter(Boolean))].sort();
|
|
@@ -4929,30 +5957,42 @@ export class AUNClient {
|
|
|
4929
5957
|
const pushFrom = isJsonObject(data) ? String(data.from_aid ?? '') : '';
|
|
4930
5958
|
const pushMsgId = isJsonObject(data) ? String(data.message_id ?? '') : '';
|
|
4931
5959
|
const envelopeJson = isJsonObject(data) ? data.envelope_json : undefined;
|
|
5960
|
+
const hasPayload = !!envelopeJson;
|
|
4932
5961
|
const ns = this._aid ? `p2p:${this._aid}` : '';
|
|
4933
5962
|
let contigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
|
|
4934
|
-
this._clientLog.debug(`_onV2PushNotification: push_seq=${pushSeq || 'null'} push_from=${pushFrom} push_msg_id=${pushMsgId} has_payload=${
|
|
5963
|
+
this._clientLog.debug(`_onV2PushNotification: push_seq=${pushSeq || 'null'} push_from=${pushFrom} push_msg_id=${pushMsgId} has_payload=${hasPayload} contiguous_seq=${contigBefore}`);
|
|
5964
|
+
// ── Push 修上界:只更新 maxSeenSeq,不动 contiguousSeq ──
|
|
5965
|
+
if (pushSeq > 0 && ns) {
|
|
5966
|
+
this._seqTracker.updateMaxSeen(ns, pushSeq);
|
|
5967
|
+
if (contigBefore === pushSeq) {
|
|
5968
|
+
this._clientLog.debug(`_onV2PushNotification: push seq=${pushSeq} already covered by contiguous_seq=${contigBefore}, ignore duplicate push`);
|
|
5969
|
+
return;
|
|
5970
|
+
}
|
|
5971
|
+
contigBefore = this._repairPushContiguousBound(ns, pushSeq, hasPayload, '_raw.peer.v2.message_received');
|
|
5972
|
+
}
|
|
4935
5973
|
// ── 带 payload 的 push:尝试就地解密 ──
|
|
4936
|
-
if (
|
|
5974
|
+
if (hasPayload && pushSeq > 0 && ns) {
|
|
4937
5975
|
try {
|
|
4938
5976
|
const decrypted = await this._decryptV2Message(data);
|
|
4939
5977
|
if (decrypted) {
|
|
4940
|
-
//
|
|
4941
|
-
this._seqTracker.onMessageSeq(ns, pushSeq);
|
|
4942
|
-
|
|
4943
|
-
this._seqTracker.forceContiguousSeq(ns, pushSeq);
|
|
4944
|
-
}
|
|
4945
|
-
await this._publishOrderedMessage('message.received', ns, pushSeq, decrypted);
|
|
5978
|
+
// 解密成功:把 pushSeq 加入 receivedSeqs,让 _tryAdvance 自然推进
|
|
5979
|
+
const needPull = this._seqTracker.onMessageSeq(ns, pushSeq);
|
|
5980
|
+
const published = await this._publishOrderedMessage('message.received', ns, pushSeq, decrypted);
|
|
4946
5981
|
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
4947
5982
|
if (newContig !== contigBefore) {
|
|
4948
5983
|
this._saveSeqTrackerState();
|
|
4949
5984
|
}
|
|
4950
5985
|
if (newContig > 0 && newContig !== contigBefore) {
|
|
4951
|
-
this.
|
|
5986
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
5987
|
+
const ackSeq = maxSeen > 0 ? Math.min(newContig, maxSeen) : newContig;
|
|
5988
|
+
this.call('message.v2.ack', { up_to_seq: ackSeq })
|
|
4952
5989
|
.catch(e => this._clientLog.debug(`V2 P2P push-ack failed: ${e}`));
|
|
4953
5990
|
}
|
|
4954
5991
|
this._clientLog.debug(`_onV2PushNotification: push 带 payload 解密成功, contiguous_seq=${contigBefore}->${newContig} push_seq=${pushSeq}`);
|
|
4955
|
-
|
|
5992
|
+
if (!needPull && (published || newContig >= pushSeq || pushSeq <= contigBefore)) {
|
|
5993
|
+
return;
|
|
5994
|
+
}
|
|
5995
|
+
this._clientLog.debug(`_onV2PushNotification: payload push seq=${pushSeq} 因空洞挂起,继续 pull 补齐 after_seq=${newContig}`);
|
|
4956
5996
|
}
|
|
4957
5997
|
}
|
|
4958
5998
|
catch (exc) {
|
|
@@ -4966,14 +6006,6 @@ export class AUNClient {
|
|
|
4966
6006
|
// 正确做法:保持 contiguousSeq 不变,用它作为 pull 的 after_seq;
|
|
4967
6007
|
// pull 成功 + 解密成功后再由 pull 路径推进 contiguousSeq。
|
|
4968
6008
|
if (pushSeq > 0 && ns) {
|
|
4969
|
-
// 越界修复:如果 contiguousSeq >= pushSeq(被之前的异常 push 污染,或恰好等于),
|
|
4970
|
-
// 强制拉回到 pushSeq - 1,确保能拉到 pushSeq 这条消息
|
|
4971
|
-
if (contigBefore >= pushSeq) {
|
|
4972
|
-
this._clientLog.warn(`_onV2PushNotification: contiguous_seq=${contigBefore} 越界(>= push_seq=${pushSeq}),强制修复为 ${pushSeq - 1}`);
|
|
4973
|
-
this._seqTracker.forceContiguousSeq(ns, pushSeq - 1);
|
|
4974
|
-
this._saveSeqTrackerState(); // 持久化修复后的状态
|
|
4975
|
-
contigBefore = pushSeq - 1;
|
|
4976
|
-
}
|
|
4977
6009
|
// 纯通知:不更新 contiguousSeq,由 pull 结果驱动推进
|
|
4978
6010
|
this._clientLog.debug(`_onV2PushNotification: 纯通知 push_seq=${pushSeq} > contiguous_seq=${contigBefore}, 触发 pull(after_seq=${contigBefore})`);
|
|
4979
6011
|
}
|
|
@@ -5035,6 +6067,66 @@ export class AUNClient {
|
|
|
5035
6067
|
this._clientLog.warn(`background task exception:${String(exc)}`);
|
|
5036
6068
|
});
|
|
5037
6069
|
}
|
|
6070
|
+
// ── Pull Gate(序列化同一 key 的并发 pull)──────────────────
|
|
6071
|
+
_pullGateKeyForCall(method, params) {
|
|
6072
|
+
if (method === 'message.pull' || method === 'message.v2.pull') {
|
|
6073
|
+
return this._aid ? `p2p:${this._aid}` : '';
|
|
6074
|
+
}
|
|
6075
|
+
if (method === 'group.pull' || method === 'group.v2.pull') {
|
|
6076
|
+
const gid = String(params.group_id ?? '').trim();
|
|
6077
|
+
return gid ? `group:${gid}` : '';
|
|
6078
|
+
}
|
|
6079
|
+
if (method === 'group.pull_events') {
|
|
6080
|
+
const gid = String(params.group_id ?? '').trim();
|
|
6081
|
+
return gid ? `group_event:${gid}` : '';
|
|
6082
|
+
}
|
|
6083
|
+
return '';
|
|
6084
|
+
}
|
|
6085
|
+
_tryAcquirePullGate(key) {
|
|
6086
|
+
if (!key)
|
|
6087
|
+
return 0;
|
|
6088
|
+
const now = Date.now();
|
|
6089
|
+
const gate = this._pullGates.get(key) ?? { inflight: false, startedAt: 0, token: 0 };
|
|
6090
|
+
if (gate.inflight && now - gate.startedAt <= AUNClient._PULL_GATE_STALE_MS) {
|
|
6091
|
+
return null;
|
|
6092
|
+
}
|
|
6093
|
+
if (gate.inflight) {
|
|
6094
|
+
this._clientLog.warn(`pull in-flight stale reset: key=${key} age=${now - gate.startedAt}ms`);
|
|
6095
|
+
}
|
|
6096
|
+
gate.token += 1;
|
|
6097
|
+
gate.inflight = true;
|
|
6098
|
+
gate.startedAt = now;
|
|
6099
|
+
this._pullGates.set(key, gate);
|
|
6100
|
+
return gate.token;
|
|
6101
|
+
}
|
|
6102
|
+
_releasePullGate(key, token) {
|
|
6103
|
+
if (!key || token == null)
|
|
6104
|
+
return;
|
|
6105
|
+
const gate = this._pullGates.get(key);
|
|
6106
|
+
if (!gate || gate.token !== token)
|
|
6107
|
+
return;
|
|
6108
|
+
gate.inflight = false;
|
|
6109
|
+
gate.startedAt = 0;
|
|
6110
|
+
}
|
|
6111
|
+
async _runPullSerialized(key, operation) {
|
|
6112
|
+
let token = this._tryAcquirePullGate(key);
|
|
6113
|
+
if (token === null) {
|
|
6114
|
+
const deadline = Date.now() + AUNClient._PULL_GATE_STALE_MS + 100;
|
|
6115
|
+
while (token === null && Date.now() <= deadline) {
|
|
6116
|
+
await this._sleep(25);
|
|
6117
|
+
token = this._tryAcquirePullGate(key);
|
|
6118
|
+
}
|
|
6119
|
+
if (token === null) {
|
|
6120
|
+
throw new StateError(`pull already in-flight for ${key}`);
|
|
6121
|
+
}
|
|
6122
|
+
}
|
|
6123
|
+
try {
|
|
6124
|
+
return await operation();
|
|
6125
|
+
}
|
|
6126
|
+
finally {
|
|
6127
|
+
this._releasePullGate(key, token);
|
|
6128
|
+
}
|
|
6129
|
+
}
|
|
5038
6130
|
/** 可取消的 sleep */
|
|
5039
6131
|
_sleep(ms) {
|
|
5040
6132
|
return new Promise((resolve) => {
|