@agentunion/fastaun-browser 0.3.2 → 0.3.3
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 +19 -0
- package/_packed_docs/CHANGELOG.md +19 -0
- package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +48 -15
- package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +182 -28
- package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +7 -5
- package/_packed_docs/sdk/INDEX.md +17 -12
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +1 -4
- package/dist/auth.js.map +1 -1
- package/dist/bundle.js +2093 -602
- package/dist/client.d.ts +64 -7
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1441 -476
- 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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/keystore/index.d.ts +22 -0
- package/dist/keystore/index.d.ts.map +1 -1
- package/dist/keystore/indexeddb.d.ts +4 -1
- package/dist/keystore/indexeddb.d.ts.map +1 -1
- package/dist/keystore/indexeddb.js +104 -1
- 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 +1 -0
- package/dist/namespaces/auth.d.ts.map +1 -1
- package/dist/namespaces/auth.js +38 -0
- package/dist/namespaces/auth.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.map +1 -1
- package/dist/transport.js +18 -0
- 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 +56 -2
- 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 +39 -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 +10 -3
- package/dist/v2/session/session.d.ts.map +1 -1
- package/dist/v2/session/session.js +91 -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 +1 -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',
|
|
@@ -269,6 +288,43 @@ function _v2B64ToBytes(s) {
|
|
|
269
288
|
out[i] = bin.charCodeAt(i);
|
|
270
289
|
return out;
|
|
271
290
|
}
|
|
291
|
+
function _v2B64ToBytesStrict(s) {
|
|
292
|
+
const text = String(s ?? '').trim();
|
|
293
|
+
if (!text || text.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(text)) {
|
|
294
|
+
throw new Error('invalid base64');
|
|
295
|
+
}
|
|
296
|
+
return _v2B64ToBytes(text);
|
|
297
|
+
}
|
|
298
|
+
function _v2BytesEqual(a, b) {
|
|
299
|
+
if (a.length !== b.length)
|
|
300
|
+
return false;
|
|
301
|
+
let diff = 0;
|
|
302
|
+
for (let i = 0; i < a.length; i++)
|
|
303
|
+
diff |= a[i] ^ b[i];
|
|
304
|
+
return diff === 0;
|
|
305
|
+
}
|
|
306
|
+
function _v2ConcatBytes(...parts) {
|
|
307
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
308
|
+
const out = new Uint8Array(total);
|
|
309
|
+
let offset = 0;
|
|
310
|
+
for (const part of parts) {
|
|
311
|
+
out.set(part, offset);
|
|
312
|
+
offset += part.length;
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
function _v2LengthPrefixedTextKey(...parts) {
|
|
317
|
+
const enc = new TextEncoder();
|
|
318
|
+
return parts.map((part) => `${enc.encode(part).length}:${part};`).join('');
|
|
319
|
+
}
|
|
320
|
+
function _v2LengthPrefixedBytes(...parts) {
|
|
321
|
+
const enc = new TextEncoder();
|
|
322
|
+
const framed = [];
|
|
323
|
+
for (const part of parts) {
|
|
324
|
+
framed.push(enc.encode(`${part.length}:`), part, enc.encode(';'));
|
|
325
|
+
}
|
|
326
|
+
return _v2ConcatBytes(...framed);
|
|
327
|
+
}
|
|
272
328
|
/** Base64URL → Uint8Array(兼容缺失 padding) */
|
|
273
329
|
function _v2B64uToBytes(s) {
|
|
274
330
|
const std = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
@@ -281,12 +337,74 @@ function formatCaughtError(error) {
|
|
|
281
337
|
function v2E2eeMeta(envelope) {
|
|
282
338
|
const suite = String(envelope.suite ?? '');
|
|
283
339
|
const modeSuite = String(envelope.suite ?? 'unknown');
|
|
284
|
-
|
|
340
|
+
const meta = {
|
|
285
341
|
version: 'v2',
|
|
286
342
|
suite,
|
|
287
343
|
encryption_mode: `v2_${modeSuite}`,
|
|
288
344
|
forward_secrecy: true,
|
|
289
345
|
};
|
|
346
|
+
const protectedHeaders = metadataWithoutAuth(envelope.protected_headers);
|
|
347
|
+
if (protectedHeaders && Object.keys(protectedHeaders).length > 0) {
|
|
348
|
+
meta.protected_headers = protectedHeaders;
|
|
349
|
+
}
|
|
350
|
+
const payloadType = String(envelope.payload_type ?? protectedHeaders?.payload_type ?? '').trim();
|
|
351
|
+
if (payloadType) {
|
|
352
|
+
meta.payload_type = payloadType;
|
|
353
|
+
}
|
|
354
|
+
const context = metadataWithoutAuth(envelope.context);
|
|
355
|
+
if (context && Object.keys(context).length > 0) {
|
|
356
|
+
meta.context = context;
|
|
357
|
+
}
|
|
358
|
+
const agentMd = metadataWithoutAuth(envelope.agent_md);
|
|
359
|
+
if (agentMd && Object.keys(agentMd).length > 0) {
|
|
360
|
+
meta.agent_md = agentMd;
|
|
361
|
+
}
|
|
362
|
+
return meta;
|
|
363
|
+
}
|
|
364
|
+
function attachV2EnvelopeMetadata(message, meta) {
|
|
365
|
+
if (!meta)
|
|
366
|
+
return;
|
|
367
|
+
const payloadType = typeof meta.payload_type === 'string' ? meta.payload_type.trim() : '';
|
|
368
|
+
if (payloadType)
|
|
369
|
+
message.payload_type = payloadType;
|
|
370
|
+
if (isJsonObject(meta.protected_headers)) {
|
|
371
|
+
message.protected_headers = { ...meta.protected_headers };
|
|
372
|
+
}
|
|
373
|
+
if (isJsonObject(meta.agent_md)) {
|
|
374
|
+
message.agent_md = { ...meta.agent_md };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function attachV2EnvelopeMetadataFromSource(message, source) {
|
|
378
|
+
const envelope = extractV2EnvelopeFromSource(source);
|
|
379
|
+
if (envelope)
|
|
380
|
+
attachV2EnvelopeMetadata(message, v2E2eeMeta(envelope));
|
|
381
|
+
}
|
|
382
|
+
function extractV2EnvelopeFromSource(source) {
|
|
383
|
+
if (!isJsonObject(source))
|
|
384
|
+
return null;
|
|
385
|
+
if (isJsonObject(source.payload))
|
|
386
|
+
return source.payload;
|
|
387
|
+
if (typeof source.envelope_json === 'string' && source.envelope_json) {
|
|
388
|
+
try {
|
|
389
|
+
const parsed = JSON.parse(source.envelope_json);
|
|
390
|
+
if (isJsonObject(parsed))
|
|
391
|
+
return parsed;
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
function metadataWithoutAuth(value) {
|
|
400
|
+
if (!isJsonObject(value))
|
|
401
|
+
return null;
|
|
402
|
+
const body = {};
|
|
403
|
+
for (const [key, item] of Object.entries(value)) {
|
|
404
|
+
if (key !== '_auth')
|
|
405
|
+
body[key] = item;
|
|
406
|
+
}
|
|
407
|
+
return body;
|
|
290
408
|
}
|
|
291
409
|
function normalizeDeliveryModeConfig(raw, opts = {}) {
|
|
292
410
|
const defaultMode = String(opts.defaultMode ?? 'fanout').trim().toLowerCase() || 'fanout';
|
|
@@ -379,11 +497,13 @@ export class AUNClient {
|
|
|
379
497
|
_v2Session;
|
|
380
498
|
_v2KeyStore;
|
|
381
499
|
_v2BootstrapCache = new Map();
|
|
500
|
+
_v2SenderIKPending = new Map();
|
|
501
|
+
_v2SenderIKFetching = new Set();
|
|
382
502
|
static V2_BOOTSTRAP_TTL_MS = 60 * 60 * 1000;
|
|
383
503
|
static V2_RETRYABLE_CODES = new Set([-33011, -33012, -33050, -33052, -33054]);
|
|
384
504
|
/** V2 state 签名验证缓存:cacheKey(hex) → expiry_unix_ms */
|
|
385
505
|
_v2SigCache = new Map();
|
|
386
|
-
static _V2_SIG_CACHE_TTL =
|
|
506
|
+
static _V2_SIG_CACHE_TTL = 60 * 60 * 1000;
|
|
387
507
|
static _V2_SIG_CACHE_MAX = 16384;
|
|
388
508
|
/** V2 state chain 本地记录:group_id → [state_version, chain_hash] */
|
|
389
509
|
_v2StateChains = new Map();
|
|
@@ -405,6 +525,11 @@ export class AUNClient {
|
|
|
405
525
|
_localAgentMdEtag = '';
|
|
406
526
|
/** gateway 在 RPC envelope._meta.agent_md_etag 注入的服务端 etag;纯观察,无下游依赖。 */
|
|
407
527
|
_remoteAgentMdEtag = '';
|
|
528
|
+
/** 浏览器侧 AgentMDs 逻辑根目录,正文映射到 IndexedDB 里的 {aid}/agent.md。 */
|
|
529
|
+
_agentMdPath = '';
|
|
530
|
+
_agentMdCache = new Map();
|
|
531
|
+
_agentMdFetchInflight = new Set();
|
|
532
|
+
_agentMdListLock = Promise.resolve();
|
|
408
533
|
/** 消息序列号跟踪器(群消息 + P2P 空洞检测) */
|
|
409
534
|
_seqTracker = new SeqTracker();
|
|
410
535
|
_seqTrackerContext = null;
|
|
@@ -444,8 +569,11 @@ export class AUNClient {
|
|
|
444
569
|
root_ca_path: this.configModel.rootCaPem,
|
|
445
570
|
seed_password: this.configModel.seedPassword,
|
|
446
571
|
};
|
|
572
|
+
this._agentMdPath = this._agentMdDefaultRoot();
|
|
573
|
+
this._deviceId = getDeviceId();
|
|
447
574
|
// Logger 必须最早初始化(其他子模块构造时通过 logger 输出)
|
|
448
|
-
this._logger = new AUNLogger({ debug: _debug });
|
|
575
|
+
this._logger = new AUNLogger({ debug: _debug, aunPath: this.configModel.aunPath });
|
|
576
|
+
this._logger.bindDeviceId(this._deviceId);
|
|
449
577
|
this._clientLog = this._logger.for('aun_core.client');
|
|
450
578
|
this._logAuth = this._logger.for('aun_core.auth');
|
|
451
579
|
this._logTransport = this._logger.for('aun_core.transport');
|
|
@@ -456,7 +584,6 @@ export class AUNClient {
|
|
|
456
584
|
this._dispatcher = new EventDispatcher();
|
|
457
585
|
this._discovery = new GatewayDiscovery();
|
|
458
586
|
this._keystore = new IndexedDBKeyStore();
|
|
459
|
-
this._deviceId = getDeviceId();
|
|
460
587
|
this._slotId = '';
|
|
461
588
|
this._connectDeliveryMode = normalizeDeliveryModeConfig({ mode: 'fanout' });
|
|
462
589
|
this._defaultConnectDeliveryMode = { ...this._connectDeliveryMode };
|
|
@@ -475,7 +602,11 @@ export class AUNClient {
|
|
|
475
602
|
timeout: DEFAULT_SESSION_OPTIONS.timeouts.call,
|
|
476
603
|
onDisconnect: (error, closeCode) => this._handleTransportDisconnect(error, closeCode),
|
|
477
604
|
});
|
|
478
|
-
this._transport.setMetaObserver((meta) =>
|
|
605
|
+
this._transport.setMetaObserver((meta) => {
|
|
606
|
+
void this._observeRpcMeta(meta).catch((exc) => {
|
|
607
|
+
this._clientLog.debug(`agent.md meta observer skipped: ${String(exc)}`);
|
|
608
|
+
});
|
|
609
|
+
});
|
|
479
610
|
this.auth = new AuthNamespace(this);
|
|
480
611
|
this.custody = new CustodyNamespace(this);
|
|
481
612
|
this.meta = new MetaNamespace(this);
|
|
@@ -548,30 +679,64 @@ export class AUNClient {
|
|
|
548
679
|
get aid() {
|
|
549
680
|
return this._aid;
|
|
550
681
|
}
|
|
682
|
+
setAgentMdPath(root) {
|
|
683
|
+
const next = String(root ?? '').trim() || this._agentMdDefaultRoot();
|
|
684
|
+
this._agentMdPath = next;
|
|
685
|
+
this._agentMdCache.clear();
|
|
686
|
+
return next;
|
|
687
|
+
}
|
|
688
|
+
setAgentMDPath(root) {
|
|
689
|
+
return this.setAgentMdPath(root);
|
|
690
|
+
}
|
|
691
|
+
SetAgentMDPath(root) {
|
|
692
|
+
return this.setAgentMdPath(root);
|
|
693
|
+
}
|
|
551
694
|
/**
|
|
552
|
-
* 浏览器版本 publishAgentMd
|
|
553
|
-
*
|
|
695
|
+
* 浏览器版本 publishAgentMd。默认从 {agentMdPath}/{self_aid}/agent.md 的等价 IndexedDB 正文读取,
|
|
696
|
+
* 然后签名、上传,并刷新 list.json 元数据。
|
|
554
697
|
*
|
|
555
|
-
*
|
|
698
|
+
* 兼容旧浏览器调用:传入 content 时会先写入等价正文,再从该正文发布。
|
|
556
699
|
*/
|
|
557
700
|
async publishAgentMd(content) {
|
|
558
|
-
const
|
|
559
|
-
if (
|
|
560
|
-
throw new ValidationError('publishAgentMd requires
|
|
701
|
+
const target = this._agentMdOwnerAid();
|
|
702
|
+
if (!target) {
|
|
703
|
+
throw new ValidationError('publishAgentMd requires local AID');
|
|
704
|
+
}
|
|
705
|
+
if (content !== undefined && content !== null) {
|
|
706
|
+
const text = String(content ?? '');
|
|
707
|
+
if (text.length === 0) {
|
|
708
|
+
throw new ValidationError('publishAgentMd requires non-empty content');
|
|
709
|
+
}
|
|
710
|
+
await this._saveAgentMdRecord(target, {
|
|
711
|
+
content: text,
|
|
712
|
+
local_etag: await this._agentMdContentEtag(text),
|
|
713
|
+
fetched_at: Date.now(),
|
|
714
|
+
});
|
|
561
715
|
}
|
|
562
|
-
const
|
|
716
|
+
const localContent = await this._readAgentMdContent(target);
|
|
717
|
+
if (localContent === null || localContent.length === 0) {
|
|
718
|
+
throw new ValidationError('publishAgentMd requires local agent.md content');
|
|
719
|
+
}
|
|
720
|
+
const signed = await this.auth.signAgentMd(localContent);
|
|
563
721
|
const result = await this.auth.uploadAgentMd(signed);
|
|
564
|
-
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
.
|
|
568
|
-
|
|
569
|
-
|
|
722
|
+
this._localAgentMdEtag = await this._agentMdContentEtag(signed);
|
|
723
|
+
const remoteEtag = isJsonObject(result) ? String(result.etag ?? '').trim() : '';
|
|
724
|
+
if (remoteEtag)
|
|
725
|
+
this._remoteAgentMdEtag = remoteEtag;
|
|
726
|
+
await this._saveAgentMdRecord(target, {
|
|
727
|
+
content: signed,
|
|
728
|
+
local_etag: this._localAgentMdEtag,
|
|
729
|
+
remote_etag: remoteEtag || undefined,
|
|
730
|
+
last_modified: isJsonObject(result) ? String(result.last_modified ?? '').trim() : '',
|
|
731
|
+
fetched_at: Date.now(),
|
|
732
|
+
remote_status: remoteEtag ? 'found' : 'unknown',
|
|
733
|
+
last_error: '',
|
|
734
|
+
});
|
|
570
735
|
return result;
|
|
571
736
|
}
|
|
572
737
|
/**
|
|
573
|
-
* 浏览器版本 fetchAgentMd。aid
|
|
574
|
-
*
|
|
738
|
+
* 浏览器版本 fetchAgentMd。aid 缺省时取自身;下载后的正文固定写入
|
|
739
|
+
* {agentMdPath}/{aid}/agent.md 的等价 IndexedDB 正文,list.json 只保存元数据。
|
|
575
740
|
*/
|
|
576
741
|
async fetchAgentMd(aid) {
|
|
577
742
|
const target = String(aid ?? this._aid ?? '').trim();
|
|
@@ -581,17 +746,30 @@ export class AUNClient {
|
|
|
581
746
|
const content = await this.auth.downloadAgentMd(target);
|
|
582
747
|
const signature = await this.auth.verifyAgentMd(content, { aid: target });
|
|
583
748
|
const isSelf = target === (this._aid ?? '');
|
|
749
|
+
const localEtag = await this._agentMdContentEtag(content);
|
|
750
|
+
const cacheMeta = this._agentMdAuthCacheMeta(target);
|
|
751
|
+
const remoteEtag = String(cacheMeta.etag ?? '').trim();
|
|
752
|
+
const lastModified = String(cacheMeta.lastModified ?? cacheMeta.last_modified ?? '').trim();
|
|
753
|
+
if (isSelf) {
|
|
754
|
+
this._localAgentMdEtag = localEtag;
|
|
755
|
+
if (remoteEtag)
|
|
756
|
+
this._remoteAgentMdEtag = remoteEtag;
|
|
757
|
+
}
|
|
758
|
+
await this._saveAgentMdRecord(target, {
|
|
759
|
+
content,
|
|
760
|
+
local_etag: localEtag,
|
|
761
|
+
remote_etag: remoteEtag || undefined,
|
|
762
|
+
last_modified: lastModified || undefined,
|
|
763
|
+
fetched_at: Date.now(),
|
|
764
|
+
remote_status: 'found',
|
|
765
|
+
verify_status: isJsonObject(signature) ? String(signature.status ?? '') : '',
|
|
766
|
+
verify_error: isJsonObject(signature) ? String(signature.reason ?? '') : '',
|
|
767
|
+
last_error: '',
|
|
768
|
+
});
|
|
584
769
|
let in_sync = null;
|
|
585
770
|
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;
|
|
771
|
+
const remote = remoteEtag || this._remoteAgentMdEtag || '';
|
|
772
|
+
in_sync = localEtag && remote ? localEtag === remote : false;
|
|
595
773
|
}
|
|
596
774
|
return {
|
|
597
775
|
aid: target,
|
|
@@ -600,13 +778,434 @@ export class AUNClient {
|
|
|
600
778
|
in_sync,
|
|
601
779
|
};
|
|
602
780
|
}
|
|
781
|
+
getLocalAgentMdEtag() {
|
|
782
|
+
return this._localAgentMdEtag;
|
|
783
|
+
}
|
|
784
|
+
getRemoteAgentMdEtag() {
|
|
785
|
+
return this._remoteAgentMdEtag;
|
|
786
|
+
}
|
|
787
|
+
async _agentMdContentEtag(content) {
|
|
788
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(String(content ?? '')));
|
|
789
|
+
const hex = Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
790
|
+
return `"${hex}"`;
|
|
791
|
+
}
|
|
792
|
+
_agentMdOwnerAid() {
|
|
793
|
+
return String(this._aid ?? '').trim();
|
|
794
|
+
}
|
|
795
|
+
_agentMdDefaultRoot() {
|
|
796
|
+
return this._joinAgentMdPath(this.configModel.aunPath || '.', 'AgentMDs');
|
|
797
|
+
}
|
|
798
|
+
_joinAgentMdPath(base, name) {
|
|
799
|
+
const left = String(base ?? '').trim().replace(/[\\/]+$/g, '');
|
|
800
|
+
return left ? `${left}/${name}` : name;
|
|
801
|
+
}
|
|
802
|
+
_agentMdRoot() {
|
|
803
|
+
return this._agentMdPath || this._agentMdDefaultRoot();
|
|
804
|
+
}
|
|
805
|
+
_agentMdSafeAid(aid) {
|
|
806
|
+
const target = String(aid ?? '').trim();
|
|
807
|
+
if (!target || target.includes('/') || target.includes('\\') || target.includes('\0')) {
|
|
808
|
+
throw new ValidationError('agent.md aid is empty or contains path separators');
|
|
809
|
+
}
|
|
810
|
+
return target;
|
|
811
|
+
}
|
|
812
|
+
_agentMdListKey() {
|
|
813
|
+
return 'list.json';
|
|
814
|
+
}
|
|
815
|
+
_agentMdContentKey(aid) {
|
|
816
|
+
return `${this._agentMdSafeAid(aid)}/agent.md`;
|
|
817
|
+
}
|
|
818
|
+
async _readAgentMdStorage(logicalKey) {
|
|
819
|
+
const key = String(logicalKey ?? '').trim();
|
|
820
|
+
if (!key)
|
|
821
|
+
return null;
|
|
822
|
+
const load = this._keystore.loadAgentMdCache;
|
|
823
|
+
if (typeof load !== 'function') {
|
|
824
|
+
throw new Error('IndexedDB agent.md storage unavailable');
|
|
825
|
+
}
|
|
826
|
+
const record = await load.call(this._keystore, this._agentMdRoot(), key);
|
|
827
|
+
if (record && Object.prototype.hasOwnProperty.call(record, 'content')) {
|
|
828
|
+
return String(record.content ?? '');
|
|
829
|
+
}
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
async _writeAgentMdStorage(logicalKey, content) {
|
|
833
|
+
const key = String(logicalKey ?? '').trim();
|
|
834
|
+
if (!key)
|
|
835
|
+
return;
|
|
836
|
+
const save = this._keystore.upsertAgentMdCache;
|
|
837
|
+
if (typeof save !== 'function') {
|
|
838
|
+
throw new Error('IndexedDB agent.md storage unavailable');
|
|
839
|
+
}
|
|
840
|
+
const text = String(content ?? '');
|
|
841
|
+
await save.call(this._keystore, this._agentMdRoot(), key, {
|
|
842
|
+
content: text,
|
|
843
|
+
local_etag: await this._agentMdContentEtag(text),
|
|
844
|
+
fetched_at: Date.now(),
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
async _listAgentMdContentAids() {
|
|
848
|
+
const list = this._keystore.listAgentMdContentAids;
|
|
849
|
+
if (typeof list !== 'function') {
|
|
850
|
+
throw new Error('IndexedDB agent.md storage unavailable');
|
|
851
|
+
}
|
|
852
|
+
return await list.call(this._keystore, this._agentMdRoot());
|
|
853
|
+
}
|
|
854
|
+
async _withAgentMdListLock(fn) {
|
|
855
|
+
const previous = this._agentMdListLock.catch(() => undefined);
|
|
856
|
+
let release;
|
|
857
|
+
const current = new Promise((resolve) => { release = resolve; });
|
|
858
|
+
this._agentMdListLock = previous.then(() => current);
|
|
859
|
+
await previous;
|
|
860
|
+
try {
|
|
861
|
+
return await fn();
|
|
862
|
+
}
|
|
863
|
+
finally {
|
|
864
|
+
release();
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
_normalizeAgentMdList(data) {
|
|
868
|
+
const records = {};
|
|
869
|
+
let iterable = [];
|
|
870
|
+
if (isJsonObject(data)) {
|
|
871
|
+
const payload = data;
|
|
872
|
+
if (Array.isArray(payload.records))
|
|
873
|
+
iterable = payload.records;
|
|
874
|
+
else if (isJsonObject(payload.records))
|
|
875
|
+
iterable = Object.values(payload.records);
|
|
876
|
+
}
|
|
877
|
+
else if (Array.isArray(data)) {
|
|
878
|
+
iterable = data;
|
|
879
|
+
}
|
|
880
|
+
for (const item of iterable) {
|
|
881
|
+
if (!isJsonObject(item))
|
|
882
|
+
continue;
|
|
883
|
+
const raw = item;
|
|
884
|
+
const aid = String(raw.aid ?? '').trim();
|
|
885
|
+
if (!aid)
|
|
886
|
+
continue;
|
|
887
|
+
const record = {};
|
|
888
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
889
|
+
if (key !== 'content')
|
|
890
|
+
record[key] = value;
|
|
891
|
+
}
|
|
892
|
+
record.aid = aid;
|
|
893
|
+
for (const key of ['fetched_at', 'observed_at', 'checked_at', 'updated_at']) {
|
|
894
|
+
record[key] = Number(record[key] ?? 0) || 0;
|
|
895
|
+
}
|
|
896
|
+
records[aid] = record;
|
|
897
|
+
}
|
|
898
|
+
return records;
|
|
899
|
+
}
|
|
900
|
+
async _writeAgentMdListUnlocked(records) {
|
|
901
|
+
const sorted = {};
|
|
902
|
+
for (const aid of Object.keys(records).sort())
|
|
903
|
+
sorted[aid] = records[aid];
|
|
904
|
+
await this._writeAgentMdStorage(this._agentMdListKey(), `${JSON.stringify({ version: 1, updated_at: Date.now(), records: sorted }, null, 2)}\n`);
|
|
905
|
+
}
|
|
906
|
+
async _rebuildAgentMdListUnlocked() {
|
|
907
|
+
const records = {};
|
|
908
|
+
const now = Date.now();
|
|
909
|
+
for (const aid of await this._listAgentMdContentAids()) {
|
|
910
|
+
try {
|
|
911
|
+
const content = await this._readAgentMdStorage(this._agentMdContentKey(aid));
|
|
912
|
+
if (content === null)
|
|
913
|
+
continue;
|
|
914
|
+
records[aid] = {
|
|
915
|
+
aid,
|
|
916
|
+
local_etag: await this._agentMdContentEtag(content),
|
|
917
|
+
fetched_at: now,
|
|
918
|
+
updated_at: now,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
catch (err) {
|
|
922
|
+
this._clientLog.warn(`agent.md rebuild skipped unreadable file aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
await this._writeAgentMdListUnlocked(records);
|
|
926
|
+
this._agentMdCache.clear();
|
|
927
|
+
return records;
|
|
928
|
+
}
|
|
929
|
+
async _readAgentMdListUnlocked() {
|
|
930
|
+
const raw = await this._readAgentMdStorage(this._agentMdListKey());
|
|
931
|
+
if (raw === null) {
|
|
932
|
+
return await this._rebuildAgentMdListUnlocked();
|
|
933
|
+
}
|
|
934
|
+
try {
|
|
935
|
+
return this._normalizeAgentMdList(JSON.parse(raw));
|
|
936
|
+
}
|
|
937
|
+
catch (err) {
|
|
938
|
+
this._clientLog.warn(`agent.md list.json damaged, rebuilding: ${err instanceof Error ? err.message : String(err)}`);
|
|
939
|
+
return await this._rebuildAgentMdListUnlocked();
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
async _readAgentMdContent(aid) {
|
|
943
|
+
return await this._readAgentMdStorage(this._agentMdContentKey(aid));
|
|
944
|
+
}
|
|
945
|
+
async _writeAgentMdContent(aid, content) {
|
|
946
|
+
await this._writeAgentMdStorage(this._agentMdContentKey(aid), String(content ?? ''));
|
|
947
|
+
}
|
|
948
|
+
_agentMdAuthCacheMeta(aid) {
|
|
949
|
+
try {
|
|
950
|
+
const store = this.auth._agentMdCache;
|
|
951
|
+
const record = store?.get(String(aid ?? '').trim());
|
|
952
|
+
return record && typeof record === 'object' ? { ...record } : {};
|
|
953
|
+
}
|
|
954
|
+
catch {
|
|
955
|
+
return {};
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
async _loadAgentMdRecord(aid) {
|
|
959
|
+
const target = String(aid ?? '').trim();
|
|
960
|
+
if (!target)
|
|
961
|
+
return null;
|
|
962
|
+
try {
|
|
963
|
+
const records = await this._withAgentMdListLock(async () => await this._readAgentMdListUnlocked());
|
|
964
|
+
const record = records[target];
|
|
965
|
+
if (record && typeof record === 'object') {
|
|
966
|
+
const loaded = { ...record, aid: target };
|
|
967
|
+
const content = await this._readAgentMdContent(target);
|
|
968
|
+
if (content !== null) {
|
|
969
|
+
loaded.content = content;
|
|
970
|
+
loaded.local_etag = await this._agentMdContentEtag(content);
|
|
971
|
+
}
|
|
972
|
+
else {
|
|
973
|
+
this._clientLog.warn(`agent.md content read failed: aid=${target}`);
|
|
974
|
+
}
|
|
975
|
+
this._agentMdCache.set(target, { ...loaded });
|
|
976
|
+
return { ...loaded };
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
catch (err) {
|
|
980
|
+
this._clientLog.debug(`agent.md cache load skipped: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
|
|
981
|
+
}
|
|
982
|
+
return null;
|
|
983
|
+
}
|
|
984
|
+
async _saveAgentMdRecord(aid, fields) {
|
|
985
|
+
const target = String(aid ?? '').trim();
|
|
986
|
+
if (!target)
|
|
987
|
+
return {};
|
|
988
|
+
try {
|
|
989
|
+
const inputFields = { ...fields };
|
|
990
|
+
const hasContent = Object.prototype.hasOwnProperty.call(inputFields, 'content') && inputFields.content !== undefined && inputFields.content !== null;
|
|
991
|
+
if (hasContent) {
|
|
992
|
+
const text = String(inputFields.content ?? '');
|
|
993
|
+
await this._writeAgentMdContent(target, text);
|
|
994
|
+
if (!inputFields.local_etag)
|
|
995
|
+
inputFields.local_etag = await this._agentMdContentEtag(text);
|
|
996
|
+
if (!inputFields.fetched_at)
|
|
997
|
+
inputFields.fetched_at = Date.now();
|
|
998
|
+
}
|
|
999
|
+
delete inputFields.content;
|
|
1000
|
+
const record = await this._withAgentMdListLock(async () => {
|
|
1001
|
+
const records = await this._readAgentMdListUnlocked();
|
|
1002
|
+
const next = { ...(records[target] ?? {}), aid: target };
|
|
1003
|
+
for (const [key, value] of Object.entries(inputFields)) {
|
|
1004
|
+
if (value !== undefined && value !== null)
|
|
1005
|
+
next[key] = value;
|
|
1006
|
+
}
|
|
1007
|
+
next.updated_at = Date.now();
|
|
1008
|
+
records[target] = { ...next };
|
|
1009
|
+
await this._writeAgentMdListUnlocked(records);
|
|
1010
|
+
return next;
|
|
1011
|
+
});
|
|
1012
|
+
const loaded = { ...record };
|
|
1013
|
+
if (hasContent)
|
|
1014
|
+
loaded.content = String(fields.content ?? '');
|
|
1015
|
+
this._agentMdCache.set(target, { ...loaded });
|
|
1016
|
+
const owner = this._agentMdOwnerAid();
|
|
1017
|
+
if (target === owner) {
|
|
1018
|
+
const localEtag = String(loaded.local_etag ?? '').trim();
|
|
1019
|
+
const remoteEtag = String(loaded.remote_etag ?? '').trim();
|
|
1020
|
+
if (localEtag)
|
|
1021
|
+
this._localAgentMdEtag = localEtag;
|
|
1022
|
+
if (remoteEtag)
|
|
1023
|
+
this._remoteAgentMdEtag = remoteEtag;
|
|
1024
|
+
}
|
|
1025
|
+
return { ...loaded };
|
|
1026
|
+
}
|
|
1027
|
+
catch (err) {
|
|
1028
|
+
this._clientLog.debug(`agent.md cache save skipped: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
|
|
1029
|
+
}
|
|
1030
|
+
return {};
|
|
1031
|
+
}
|
|
1032
|
+
async _agentMdHasLocalContent(aid, record) {
|
|
1033
|
+
if (record && typeof record.content === 'string' && record.content.length > 0)
|
|
1034
|
+
return true;
|
|
1035
|
+
try {
|
|
1036
|
+
return (await this._readAgentMdContent(aid)) !== null;
|
|
1037
|
+
}
|
|
1038
|
+
catch {
|
|
1039
|
+
return false;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
_agentMdCheckedAtFresh(checkedAtMs, maxUnsyncedDays) {
|
|
1043
|
+
const days = Number(maxUnsyncedDays || 0);
|
|
1044
|
+
if (!Number.isFinite(days) || days <= 0)
|
|
1045
|
+
return false;
|
|
1046
|
+
if (!Number.isFinite(checkedAtMs) || checkedAtMs <= 0)
|
|
1047
|
+
return false;
|
|
1048
|
+
return Date.now() - checkedAtMs <= days * 86400000;
|
|
1049
|
+
}
|
|
1050
|
+
_agentMdLastModifiedFresh(lastModified, maxUnsyncedDays) {
|
|
1051
|
+
const days = Number(maxUnsyncedDays || 0);
|
|
1052
|
+
if (!Number.isFinite(days) || days <= 0)
|
|
1053
|
+
return false;
|
|
1054
|
+
const ts = Date.parse(String(lastModified ?? '').trim());
|
|
1055
|
+
if (!Number.isFinite(ts))
|
|
1056
|
+
return false;
|
|
1057
|
+
return Date.now() <= ts + days * 86400000;
|
|
1058
|
+
}
|
|
1059
|
+
async _scheduleAgentMdFetchIfMissing(aid, record, source = '') {
|
|
1060
|
+
const target = String(aid ?? '').trim();
|
|
1061
|
+
if (!target || await this._agentMdHasLocalContent(target, record))
|
|
1062
|
+
return;
|
|
1063
|
+
if (this._agentMdFetchInflight.has(target))
|
|
1064
|
+
return;
|
|
1065
|
+
this._agentMdFetchInflight.add(target);
|
|
1066
|
+
try {
|
|
1067
|
+
await this.fetchAgentMd(target);
|
|
1068
|
+
}
|
|
1069
|
+
catch (err) {
|
|
1070
|
+
await this._saveAgentMdRecord(target, {
|
|
1071
|
+
last_error: err instanceof Error ? err.message : String(err),
|
|
1072
|
+
remote_status: 'found',
|
|
1073
|
+
});
|
|
1074
|
+
this._clientLog.debug(`agent.md auto fetch failed: aid=${target} source=${source || '-'} err=${err instanceof Error ? err.message : String(err)}`);
|
|
1075
|
+
}
|
|
1076
|
+
finally {
|
|
1077
|
+
this._agentMdFetchInflight.delete(target);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
async _observeAgentMdMeta(aid, etag = '', lastModified = '', source = '') {
|
|
1081
|
+
const target = String(aid ?? '').trim();
|
|
1082
|
+
const remoteEtag = String(etag ?? '').trim();
|
|
1083
|
+
const remoteLastModified = String(lastModified ?? '').trim();
|
|
1084
|
+
if (!target || (!remoteEtag && !remoteLastModified))
|
|
1085
|
+
return;
|
|
1086
|
+
let before = this._agentMdCache.get(target);
|
|
1087
|
+
if (!before || typeof before !== 'object')
|
|
1088
|
+
before = await this._loadAgentMdRecord(target) ?? {};
|
|
1089
|
+
const same = (!remoteEtag || String(before.remote_etag ?? '').trim() === remoteEtag) &&
|
|
1090
|
+
(!remoteLastModified || String(before.last_modified ?? '').trim() === remoteLastModified);
|
|
1091
|
+
let record = { ...before };
|
|
1092
|
+
if (!same || Object.keys(before).length === 0) {
|
|
1093
|
+
const fields = {
|
|
1094
|
+
observed_at: Date.now(),
|
|
1095
|
+
remote_status: 'found',
|
|
1096
|
+
};
|
|
1097
|
+
if (remoteEtag)
|
|
1098
|
+
fields.remote_etag = remoteEtag;
|
|
1099
|
+
if (remoteLastModified)
|
|
1100
|
+
fields.last_modified = remoteLastModified;
|
|
1101
|
+
record = await this._saveAgentMdRecord(target, fields) || record;
|
|
1102
|
+
}
|
|
1103
|
+
if (target === this._agentMdOwnerAid() && remoteEtag)
|
|
1104
|
+
this._remoteAgentMdEtag = remoteEtag;
|
|
1105
|
+
await this._scheduleAgentMdFetchIfMissing(target, record, source);
|
|
1106
|
+
this._clientLog.debug(`agent.md meta observed: aid=${target} etag=${remoteEtag || '-'} last_modified=${remoteLastModified || '-'} source=${source || '-'}`);
|
|
1107
|
+
}
|
|
1108
|
+
async _observeAgentMdEtag(aid, etag, source = '') {
|
|
1109
|
+
await this._observeAgentMdMeta(aid, etag, '', source);
|
|
1110
|
+
}
|
|
1111
|
+
async _observeAgentMdFromEnvelope(envelope) {
|
|
1112
|
+
if (!isJsonObject(envelope))
|
|
1113
|
+
return;
|
|
1114
|
+
const env = envelope;
|
|
1115
|
+
if (!isJsonObject(env.agent_md))
|
|
1116
|
+
return;
|
|
1117
|
+
const agentMd = env.agent_md;
|
|
1118
|
+
if (!isJsonObject(agentMd.sender))
|
|
1119
|
+
return;
|
|
1120
|
+
const sender = agentMd.sender;
|
|
1121
|
+
let senderAid = String(sender.aid ?? '').trim();
|
|
1122
|
+
if (!senderAid) {
|
|
1123
|
+
const aad = isJsonObject(env.aad) ? env.aad : {};
|
|
1124
|
+
senderAid = String(aad.from ?? env.from ?? '').trim();
|
|
1125
|
+
}
|
|
1126
|
+
await this._observeAgentMdMeta(senderAid, String(sender.etag ?? '').trim(), String(sender.last_modified ?? sender.lastModified ?? '').trim(), 'envelope');
|
|
1127
|
+
}
|
|
1128
|
+
async checkAgentMd(aid, maxUnsyncedDays = 0) {
|
|
1129
|
+
const target = String(aid ?? this._aid ?? '').trim();
|
|
1130
|
+
if (!target)
|
|
1131
|
+
throw new ValidationError('checkAgentMd requires aid (or local AID)');
|
|
1132
|
+
const before = await this._loadAgentMdRecord(target) ?? {};
|
|
1133
|
+
const localEtag = String(before.local_etag ?? '').trim();
|
|
1134
|
+
const localFound = !!(Object.keys(before).length > 0 && (String(before.content ?? '') || localEtag));
|
|
1135
|
+
const remoteEtagCached = String(before.remote_etag ?? '').trim();
|
|
1136
|
+
const lastModifiedCached = String(before.last_modified ?? '').trim();
|
|
1137
|
+
const checkedAtCached = Number(before.checked_at ?? 0);
|
|
1138
|
+
const cachedInSync = !!(localFound && localEtag && remoteEtagCached && localEtag === remoteEtagCached);
|
|
1139
|
+
// max_unsynced_days > 0 且距上次 HEAD 在窗口内 → 直接返回缓存;否则强制 HEAD。
|
|
1140
|
+
if (cachedInSync && this._agentMdCheckedAtFresh(checkedAtCached, maxUnsyncedDays)) {
|
|
1141
|
+
return {
|
|
1142
|
+
aid: target,
|
|
1143
|
+
local_found: true,
|
|
1144
|
+
remote_found: true,
|
|
1145
|
+
local_etag: localEtag,
|
|
1146
|
+
remote_etag: remoteEtagCached,
|
|
1147
|
+
in_sync: true,
|
|
1148
|
+
last_modified: lastModifiedCached,
|
|
1149
|
+
status: 200,
|
|
1150
|
+
cached: true,
|
|
1151
|
+
verify_status: String(before.verify_status ?? ''),
|
|
1152
|
+
verify_error: String(before.verify_error ?? ''),
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
const now = Date.now();
|
|
1156
|
+
let remote;
|
|
1157
|
+
try {
|
|
1158
|
+
remote = await this.auth.headAgentMd(target);
|
|
1159
|
+
}
|
|
1160
|
+
catch (err) {
|
|
1161
|
+
await this._saveAgentMdRecord(target, { checked_at: now, remote_status: 'error', last_error: err instanceof Error ? err.message : String(err) });
|
|
1162
|
+
throw err;
|
|
1163
|
+
}
|
|
1164
|
+
const remoteFound = !!remote.found;
|
|
1165
|
+
const remoteEtag = String(remote.etag ?? '').trim();
|
|
1166
|
+
const lastModified = String(remote.last_modified ?? remote.lastModified ?? '').trim();
|
|
1167
|
+
const saved = await this._saveAgentMdRecord(target, {
|
|
1168
|
+
remote_etag: remoteFound ? remoteEtag : '',
|
|
1169
|
+
last_modified: lastModified,
|
|
1170
|
+
checked_at: now,
|
|
1171
|
+
remote_status: remoteFound ? 'found' : 'missing',
|
|
1172
|
+
last_error: '',
|
|
1173
|
+
});
|
|
1174
|
+
if (target === this._agentMdOwnerAid() && remoteEtag)
|
|
1175
|
+
this._remoteAgentMdEtag = remoteEtag;
|
|
1176
|
+
const inSync = !!(localFound && remoteFound && localEtag && remoteEtag && localEtag === remoteEtag);
|
|
1177
|
+
return {
|
|
1178
|
+
aid: target,
|
|
1179
|
+
local_found: localFound,
|
|
1180
|
+
remote_found: remoteFound,
|
|
1181
|
+
local_etag: localEtag,
|
|
1182
|
+
remote_etag: remoteEtag,
|
|
1183
|
+
in_sync: inSync,
|
|
1184
|
+
last_modified: lastModified,
|
|
1185
|
+
status: Number(remote.status ?? (remoteFound ? 200 : 404)),
|
|
1186
|
+
cached: false,
|
|
1187
|
+
verify_status: String(saved.verify_status ?? before.verify_status ?? ''),
|
|
1188
|
+
verify_error: String(saved.verify_error ?? before.verify_error ?? ''),
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
603
1191
|
/** transport 的 meta observer:吸收 gateway 注入的 _meta 字段。失败不影响业务。 */
|
|
604
|
-
_observeRpcMeta(meta) {
|
|
1192
|
+
async _observeRpcMeta(meta) {
|
|
605
1193
|
if (!isJsonObject(meta))
|
|
606
1194
|
return;
|
|
607
1195
|
const etag = String(meta.agent_md_etag ?? '').trim();
|
|
608
1196
|
if (etag) {
|
|
609
1197
|
this._remoteAgentMdEtag = etag;
|
|
1198
|
+
await this._observeAgentMdMeta(this._aid ?? '', etag, '', 'rpc.self');
|
|
1199
|
+
}
|
|
1200
|
+
const etags = meta.agent_md_etags;
|
|
1201
|
+
if (isJsonObject(etags)) {
|
|
1202
|
+
// role key 优先级:requester / peer 是新规范,其余是兼容旧 SDK 的别名。
|
|
1203
|
+
for (const key of ['requester', 'peer', 'receiver', 'target', 'to', 'sender', 'from']) {
|
|
1204
|
+
const item = etags[key];
|
|
1205
|
+
if (!isJsonObject(item))
|
|
1206
|
+
continue;
|
|
1207
|
+
await this._observeAgentMdMeta(String(item.aid ?? ''), String(item.etag ?? ''), String(item.last_modified ?? item.lastModified ?? ''), `rpc.${key}`);
|
|
1208
|
+
}
|
|
610
1209
|
}
|
|
611
1210
|
}
|
|
612
1211
|
get state() {
|
|
@@ -660,18 +1259,30 @@ export class AUNClient {
|
|
|
660
1259
|
this._sessionOptions = this._buildSessionOptions(normalized);
|
|
661
1260
|
this._transport.setTimeout(this._sessionOptions.timeouts.call);
|
|
662
1261
|
this._closing = false;
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
1262
|
+
const gateways = this._resolveGateways(normalized);
|
|
1263
|
+
let lastErr = null;
|
|
1264
|
+
for (const gw of gateways) {
|
|
1265
|
+
try {
|
|
1266
|
+
const gwParams = { ...normalized, gateway: gw };
|
|
1267
|
+
await this._connectOnce(gwParams, false);
|
|
1268
|
+
this._clientLog.debug(`connect exit: elapsed=${Date.now() - tStart}ms state=${this._state}`);
|
|
1269
|
+
return;
|
|
671
1270
|
}
|
|
672
|
-
|
|
673
|
-
|
|
1271
|
+
catch (err) {
|
|
1272
|
+
lastErr = err;
|
|
1273
|
+
if (gateways.length > 1) {
|
|
1274
|
+
this._clientLog.warn(`connect: gateway ${gw} failed, trying next: ${err instanceof Error ? err.message : String(err)}`);
|
|
1275
|
+
}
|
|
1276
|
+
if (this._state === 'connecting' || this._state === 'authenticating') {
|
|
1277
|
+
this._state = 'connecting';
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
if (this._state === 'connecting' || this._state === 'authenticating') {
|
|
1282
|
+
this._state = 'disconnected';
|
|
674
1283
|
}
|
|
1284
|
+
this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
|
|
1285
|
+
throw lastErr;
|
|
675
1286
|
}
|
|
676
1287
|
/** 断开连接但保留本地状态,可再次 connect */
|
|
677
1288
|
async disconnect() {
|
|
@@ -803,6 +1414,9 @@ export class AUNClient {
|
|
|
803
1414
|
throw new PermissionError(`legacy E2EE method is removed in this SDK: ${method}`);
|
|
804
1415
|
}
|
|
805
1416
|
const p = { ...(params ?? {}) };
|
|
1417
|
+
if (method === 'message.send' || method === 'group.send') {
|
|
1418
|
+
this._normalizeOutboundMessagePayload(p, method);
|
|
1419
|
+
}
|
|
806
1420
|
this._validateOutboundCall(method, p);
|
|
807
1421
|
this._injectMessageCursorContext(method, p);
|
|
808
1422
|
// group.* 方法的 group_id 归一化为 canonical 格式(兼容老/污染数据)
|
|
@@ -815,7 +1429,7 @@ export class AUNClient {
|
|
|
815
1429
|
p.group_id = normalizedGroupId;
|
|
816
1430
|
}
|
|
817
1431
|
// group.* 方法注入 device_id(服务端用于多设备消息路由)
|
|
818
|
-
if (method.startsWith('group.') &&
|
|
1432
|
+
if (method.startsWith('group.') && p.device_id === undefined) {
|
|
819
1433
|
p.device_id = this._deviceId;
|
|
820
1434
|
}
|
|
821
1435
|
if (method.startsWith('group.') && p.slot_id === undefined) {
|
|
@@ -904,7 +1518,12 @@ export class AUNClient {
|
|
|
904
1518
|
}
|
|
905
1519
|
// 关键操作自动附加客户端签名
|
|
906
1520
|
if (SIGNED_METHODS.has(method)) {
|
|
907
|
-
|
|
1521
|
+
if (this._shouldSkipClientSignature(method, p)) {
|
|
1522
|
+
delete p.client_signature;
|
|
1523
|
+
}
|
|
1524
|
+
else {
|
|
1525
|
+
await this._signClientOperation(method, p);
|
|
1526
|
+
}
|
|
908
1527
|
}
|
|
909
1528
|
// P1-23: 非幂等方法使用更长超时
|
|
910
1529
|
const callTimeout = NON_IDEMPOTENT_METHODS.has(method) ? NON_IDEMPOTENT_TIMEOUT : undefined;
|
|
@@ -1042,6 +1661,9 @@ export class AUNClient {
|
|
|
1042
1661
|
const seq = msg.seq;
|
|
1043
1662
|
if (seq !== undefined && seq !== null && this._aid) {
|
|
1044
1663
|
const ns = `p2p:${this._aid}`;
|
|
1664
|
+
// Push 修上界:先更新 maxSeenSeq
|
|
1665
|
+
if (seq > 0)
|
|
1666
|
+
this._seqTracker.updateMaxSeen(ns, seq);
|
|
1045
1667
|
const needPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
1046
1668
|
if (needPull) {
|
|
1047
1669
|
this._safeAsync(this._fillP2pGap());
|
|
@@ -1049,8 +1671,10 @@ export class AUNClient {
|
|
|
1049
1671
|
// auto-ack contiguous_seq
|
|
1050
1672
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1051
1673
|
if (contig > 0) {
|
|
1674
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
1675
|
+
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
1052
1676
|
this._transport.call('message.ack', {
|
|
1053
|
-
seq:
|
|
1677
|
+
seq: ackSeq,
|
|
1054
1678
|
device_id: this._deviceId,
|
|
1055
1679
|
slot_id: this._slotId,
|
|
1056
1680
|
}).catch((e) => { this._clientLog.warn(`P2P auto-ack failed:${String(e)}`); });
|
|
@@ -1079,6 +1703,7 @@ export class AUNClient {
|
|
|
1079
1703
|
timestamp: (src.timestamp ?? null),
|
|
1080
1704
|
_decrypt_error: String(exc),
|
|
1081
1705
|
};
|
|
1706
|
+
attachV2EnvelopeMetadataFromSource(safeEvent, data);
|
|
1082
1707
|
await this._publishAppEvent('message.undecryptable', safeEvent);
|
|
1083
1708
|
}
|
|
1084
1709
|
}
|
|
@@ -1111,6 +1736,14 @@ export class AUNClient {
|
|
|
1111
1736
|
}
|
|
1112
1737
|
try {
|
|
1113
1738
|
const ns = `group:${groupId}`;
|
|
1739
|
+
// Push 修上界:先更新 maxSeenSeq
|
|
1740
|
+
this._seqTracker.updateMaxSeen(ns, seq);
|
|
1741
|
+
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1742
|
+
if (contigBefore === seq) {
|
|
1743
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated duplicate push already covered: group=${groupId} seq=${seq}`);
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
const afterSeq = this._repairPushContiguousBound(ns, seq, false, '_raw.group.v2.message_created');
|
|
1114
1747
|
// per-namespace 去重:同一 group namespace 只允许 1 个 in-flight pull
|
|
1115
1748
|
const dedupKey = `group_pull:${ns}`;
|
|
1116
1749
|
if (this._gapFillDone.has(dedupKey)) {
|
|
@@ -1119,7 +1752,6 @@ export class AUNClient {
|
|
|
1119
1752
|
}
|
|
1120
1753
|
this._gapFillDone.add(dedupKey);
|
|
1121
1754
|
try {
|
|
1122
|
-
const afterSeq = Math.max(0, this._seqTracker.getContiguousSeq(ns));
|
|
1123
1755
|
this._clientLog.debug(`_onRawGroupV2MessageCreated -> group.v2.pull group=${groupId} after_seq=${afterSeq}`);
|
|
1124
1756
|
const messages = await this.pullGroupV2(groupId, afterSeq, 50);
|
|
1125
1757
|
this._clientLog.debug(`_onRawGroupV2MessageCreated pulled ${messages.length} msgs for group=${groupId}`);
|
|
@@ -1164,15 +1796,20 @@ export class AUNClient {
|
|
|
1164
1796
|
// seq 跟踪 + auto-ack
|
|
1165
1797
|
if (groupId && seq !== undefined && seq !== null) {
|
|
1166
1798
|
const ns = `group:${groupId}`;
|
|
1799
|
+
// Push 修上界:先更新 maxSeenSeq
|
|
1800
|
+
if (seq > 0)
|
|
1801
|
+
this._seqTracker.updateMaxSeen(ns, seq);
|
|
1167
1802
|
const needPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
1168
1803
|
if (needPull) {
|
|
1169
1804
|
this._safeAsync(this._fillGroupGap(groupId));
|
|
1170
1805
|
}
|
|
1171
1806
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1172
1807
|
if (contig > 0) {
|
|
1808
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
1809
|
+
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
1173
1810
|
this._transport.call('group.ack_messages', {
|
|
1174
1811
|
group_id: groupId,
|
|
1175
|
-
msg_seq:
|
|
1812
|
+
msg_seq: ackSeq,
|
|
1176
1813
|
device_id: this._deviceId,
|
|
1177
1814
|
slot_id: this._slotId,
|
|
1178
1815
|
}).catch((e) => { this._clientLog.warn('group message auto-ack failed: group=' + groupId, e); });
|
|
@@ -1200,6 +1837,7 @@ export class AUNClient {
|
|
|
1200
1837
|
timestamp: (src.timestamp ?? null),
|
|
1201
1838
|
_decrypt_error: String(exc),
|
|
1202
1839
|
};
|
|
1840
|
+
attachV2EnvelopeMetadataFromSource(safeEvent, data);
|
|
1203
1841
|
await this._publishAppEvent('group.message_undecryptable', safeEvent);
|
|
1204
1842
|
}
|
|
1205
1843
|
}
|
|
@@ -1331,53 +1969,80 @@ export class AUNClient {
|
|
|
1331
1969
|
this._gapFillDone.add(dedupKey);
|
|
1332
1970
|
this._gapFillActive = true;
|
|
1333
1971
|
try {
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1972
|
+
let nextAfterSeq = afterSeq;
|
|
1973
|
+
const maxPages = 100;
|
|
1974
|
+
let pageCount = 0;
|
|
1975
|
+
while (pageCount < maxPages) {
|
|
1976
|
+
pageCount += 1;
|
|
1977
|
+
const result = await this.call('group.pull_events', {
|
|
1978
|
+
group_id: groupId,
|
|
1979
|
+
after_event_seq: nextAfterSeq,
|
|
1980
|
+
device_id: this._deviceId,
|
|
1981
|
+
limit: 50,
|
|
1982
|
+
});
|
|
1983
|
+
if (!isJsonObject(result))
|
|
1984
|
+
return;
|
|
1341
1985
|
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); });
|
|
1986
|
+
if (!Array.isArray(events))
|
|
1987
|
+
return;
|
|
1988
|
+
const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1989
|
+
const eventObjects = events.filter(isJsonObject);
|
|
1990
|
+
if (eventObjects.length > 0) {
|
|
1991
|
+
this._seqTracker.onPullResult(ns, eventObjects, nextAfterSeq);
|
|
1992
|
+
}
|
|
1993
|
+
const cursor = isJsonObject(result.cursor) ? result.cursor : null;
|
|
1994
|
+
const serverAck = cursor ? Number(cursor.current_seq ?? 0) : 0;
|
|
1995
|
+
if (serverAck > 0) {
|
|
1996
|
+
const contigBeforeFloor = this._seqTracker.getContiguousSeq(ns);
|
|
1997
|
+
if (contigBeforeFloor < serverAck) {
|
|
1998
|
+
this._clientLog.info('group.pull_events retention-floor advance: ns=' + ns + ' contiguous=' + contigBeforeFloor + ' -> cursor.current_seq=' + serverAck);
|
|
1999
|
+
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
1363
2000
|
}
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
2001
|
+
}
|
|
2002
|
+
const eventSeqs = [];
|
|
2003
|
+
for (const evt of eventObjects) {
|
|
2004
|
+
const eventSeq = Number(evt.event_seq ?? 0);
|
|
2005
|
+
if (Number.isFinite(eventSeq) && eventSeq > 0)
|
|
2006
|
+
eventSeqs.push(eventSeq);
|
|
2007
|
+
evt._from_gap_fill = true;
|
|
2008
|
+
const et = String(evt.event_type ?? '');
|
|
2009
|
+
// 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
|
|
2010
|
+
if (et === 'group.message_created')
|
|
2011
|
+
continue;
|
|
2012
|
+
// 验签:有 client_signature 就验(与实时事件路径对齐)
|
|
2013
|
+
const cs = evt.client_signature;
|
|
2014
|
+
if (cs && typeof cs === 'object') {
|
|
2015
|
+
if (this._shouldSkipEventSignature(evt)) {
|
|
2016
|
+
delete evt.client_signature;
|
|
2017
|
+
}
|
|
2018
|
+
else {
|
|
2019
|
+
evt._verified = await this._verifyEventSignature(evt, cs);
|
|
1378
2020
|
}
|
|
1379
2021
|
}
|
|
2022
|
+
// group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
|
|
2023
|
+
await this._dispatcher.publish('group.changed', evt);
|
|
2024
|
+
}
|
|
2025
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2026
|
+
if (contig !== pageContigBefore) {
|
|
2027
|
+
this._saveSeqTrackerState();
|
|
1380
2028
|
}
|
|
2029
|
+
if (eventObjects.length > 0 && contig > 0 && contig !== pageContigBefore) {
|
|
2030
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
2031
|
+
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
2032
|
+
this._transport.call('group.ack_events', {
|
|
2033
|
+
group_id: groupId,
|
|
2034
|
+
event_seq: ackSeq,
|
|
2035
|
+
device_id: this._deviceId,
|
|
2036
|
+
slot_id: this._slotId,
|
|
2037
|
+
}).catch((e) => { this._clientLog.warn('group event auto-ack failed: group=' + groupId, e); });
|
|
2038
|
+
}
|
|
2039
|
+
const nextAfter = Math.max(eventSeqs.length > 0 ? Math.max(...eventSeqs) : nextAfterSeq, nextAfterSeq);
|
|
2040
|
+
if (eventObjects.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
|
|
2041
|
+
break;
|
|
2042
|
+
nextAfterSeq = nextAfter;
|
|
2043
|
+
}
|
|
2044
|
+
if (pageCount >= maxPages) {
|
|
2045
|
+
this._clientLog.warn(`group event gap fill reached max_pages=${maxPages} group=${groupId} after_seq=${nextAfterSeq}`);
|
|
1381
2046
|
}
|
|
1382
2047
|
}
|
|
1383
2048
|
catch (exc) {
|
|
@@ -1495,10 +2160,10 @@ export class AUNClient {
|
|
|
1495
2160
|
if (!isJsonObject(payload))
|
|
1496
2161
|
return payload;
|
|
1497
2162
|
const result = { ...payload };
|
|
1498
|
-
if (
|
|
2163
|
+
if (!('device_id' in result)) {
|
|
1499
2164
|
result.device_id = this._deviceId;
|
|
1500
2165
|
}
|
|
1501
|
-
if (
|
|
2166
|
+
if (!('slot_id' in result)) {
|
|
1502
2167
|
result.slot_id = this._slotId;
|
|
1503
2168
|
}
|
|
1504
2169
|
return result;
|
|
@@ -1554,6 +2219,18 @@ export class AUNClient {
|
|
|
1554
2219
|
const trace = `${this._echoTimestamp()} [AUN-SDK.send] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
|
|
1555
2220
|
params.payload = { ...payload, text: payload.text + '\n' + trace };
|
|
1556
2221
|
}
|
|
2222
|
+
_shouldSkipClientSignature(method, params) {
|
|
2223
|
+
if (method !== 'message.send' && method !== 'group.send')
|
|
2224
|
+
return false;
|
|
2225
|
+
if (params.encrypted || params.encrypt)
|
|
2226
|
+
return false;
|
|
2227
|
+
return this._isEchoPayload(params.payload);
|
|
2228
|
+
}
|
|
2229
|
+
_shouldSkipEventSignature(event) {
|
|
2230
|
+
if (event.encrypted || event.encrypt)
|
|
2231
|
+
return false;
|
|
2232
|
+
return this._isEchoPayload(event.payload);
|
|
2233
|
+
}
|
|
1557
2234
|
_maybeAppendEchoTraceReceive(msg) {
|
|
1558
2235
|
if (msg.encrypted)
|
|
1559
2236
|
return;
|
|
@@ -1567,13 +2244,17 @@ export class AUNClient {
|
|
|
1567
2244
|
_messageTargetsCurrentInstance(message) {
|
|
1568
2245
|
if (!isJsonObject(message))
|
|
1569
2246
|
return true;
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
2247
|
+
if ('device_id' in message) {
|
|
2248
|
+
const targetDeviceId = String(message.device_id ?? '').trim();
|
|
2249
|
+
if (targetDeviceId !== this._deviceId) {
|
|
2250
|
+
return false;
|
|
2251
|
+
}
|
|
1573
2252
|
}
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
2253
|
+
if ('slot_id' in message) {
|
|
2254
|
+
const targetSlotId = String(message.slot_id ?? '').trim();
|
|
2255
|
+
if (targetSlotId !== this._slotId) {
|
|
2256
|
+
return false;
|
|
2257
|
+
}
|
|
1577
2258
|
}
|
|
1578
2259
|
return true;
|
|
1579
2260
|
}
|
|
@@ -1668,7 +2349,12 @@ export class AUNClient {
|
|
|
1668
2349
|
// 验签:有 client_signature 就验,没有默认安全
|
|
1669
2350
|
const cs = d.client_signature;
|
|
1670
2351
|
if (cs && isJsonObject(cs)) {
|
|
1671
|
-
|
|
2352
|
+
if (this._shouldSkipEventSignature(d)) {
|
|
2353
|
+
delete d.client_signature;
|
|
2354
|
+
}
|
|
2355
|
+
else {
|
|
2356
|
+
d._verified = await this._verifyEventSignature(d, cs);
|
|
2357
|
+
}
|
|
1672
2358
|
}
|
|
1673
2359
|
await this._dispatcher.publish('group.changed', d);
|
|
1674
2360
|
const groupId = (d.group_id ?? '');
|
|
@@ -1677,14 +2363,20 @@ export class AUNClient {
|
|
|
1677
2363
|
this._v2BootstrapCache.delete(`group:${groupId}`);
|
|
1678
2364
|
}
|
|
1679
2365
|
// Group SPK 编排:成员变更触发注册/轮换
|
|
2366
|
+
const membershipActions = new Set([
|
|
2367
|
+
'member_added', 'member_left', 'member_removed', 'role_changed',
|
|
2368
|
+
'owner_transferred', 'joined', 'join_approved', 'invite_code_used',
|
|
2369
|
+
]);
|
|
1680
2370
|
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
2371
|
if (membershipActions.has(action)) {
|
|
1686
2372
|
const callFn = async (method, params) => this.call(method, params);
|
|
1687
|
-
|
|
2373
|
+
const joinedAid = String(d.joined_aid ?? d.member_aid ?? d.aid ?? '').trim();
|
|
2374
|
+
const actorAid = String(d.actor_aid ?? '').trim();
|
|
2375
|
+
const selfAid = String(this._aid ?? '').trim();
|
|
2376
|
+
const joinActions = new Set(['member_added', 'joined', 'join_approved', 'invite_code_used']);
|
|
2377
|
+
const isSelfJoin = joinActions.has(action) && !!selfAid && (joinedAid === selfAid ||
|
|
2378
|
+
(!joinedAid && (action === 'joined' || action === 'invite_code_used') && actorAid === selfAid));
|
|
2379
|
+
if (isSelfJoin) {
|
|
1688
2380
|
this._v2Session.ensureGroupRegistered?.(groupId, callFn)?.catch(exc => {
|
|
1689
2381
|
this._clientLog.debug(`group SPK registration failed (non-fatal): group=${groupId} action=${action} err=${exc}`);
|
|
1690
2382
|
});
|
|
@@ -1696,7 +2388,7 @@ export class AUNClient {
|
|
|
1696
2388
|
}
|
|
1697
2389
|
}
|
|
1698
2390
|
}
|
|
1699
|
-
if (groupId && action === 'upsert'
|
|
2391
|
+
if (groupId && this._v2Session && (action === 'upsert' || membershipActions.has(action))) {
|
|
1700
2392
|
this._safeAsync(this._v2AutoProposeState(groupId, { leaderDelay: true }));
|
|
1701
2393
|
}
|
|
1702
2394
|
// event_seq 空洞检测:持久化后的 group.changed 会携带 event_seq。
|
|
@@ -1761,12 +2453,17 @@ export class AUNClient {
|
|
|
1761
2453
|
// 提交者签名验证
|
|
1762
2454
|
const cs = d.client_signature;
|
|
1763
2455
|
if (cs && isJsonObject(cs)) {
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
2456
|
+
if (this._shouldSkipEventSignature(d)) {
|
|
2457
|
+
delete d.client_signature;
|
|
2458
|
+
}
|
|
2459
|
+
else {
|
|
2460
|
+
const verified = await this._verifyEventSignature(d, cs);
|
|
2461
|
+
if (verified === false) {
|
|
2462
|
+
this._clientLog.warn(`state_committed committer signature verify failed group=%s${String(groupId)}`);
|
|
2463
|
+
return;
|
|
2464
|
+
}
|
|
2465
|
+
d._verified = verified;
|
|
1768
2466
|
}
|
|
1769
|
-
d._verified = verified;
|
|
1770
2467
|
}
|
|
1771
2468
|
const stateVersion = Number(d.state_version ?? 0);
|
|
1772
2469
|
const stateHash = String(d.state_hash ?? '').trim();
|
|
@@ -2036,6 +2733,7 @@ export class AUNClient {
|
|
|
2036
2733
|
let e2eeMeta = null;
|
|
2037
2734
|
let decryptFailed = false;
|
|
2038
2735
|
if (isV2Envelope) {
|
|
2736
|
+
e2eeMeta = v2E2eeMeta(payload);
|
|
2039
2737
|
const plaintext = await this._decryptV2EnvelopeForThought({
|
|
2040
2738
|
envelope: payload,
|
|
2041
2739
|
fromAid: senderAid,
|
|
@@ -2047,7 +2745,7 @@ export class AUNClient {
|
|
|
2047
2745
|
}
|
|
2048
2746
|
else {
|
|
2049
2747
|
decryptedPayload = plaintext;
|
|
2050
|
-
const e2eeObj =
|
|
2748
|
+
const e2eeObj = e2eeMeta;
|
|
2051
2749
|
// 暴露 protected_headers(去 _auth)
|
|
2052
2750
|
const ph = payload.protected_headers;
|
|
2053
2751
|
if (isJsonObject(ph)) {
|
|
@@ -2084,6 +2782,8 @@ export class AUNClient {
|
|
|
2084
2782
|
created_at: item.created_at,
|
|
2085
2783
|
e2ee: e2eeMeta,
|
|
2086
2784
|
};
|
|
2785
|
+
if (isJsonObject(e2eeMeta))
|
|
2786
|
+
attachV2EnvelopeMetadata(thought, e2eeMeta);
|
|
2087
2787
|
if (decryptFailed)
|
|
2088
2788
|
thought.decrypt_failed = true;
|
|
2089
2789
|
if ('context' in item)
|
|
@@ -2122,6 +2822,8 @@ export class AUNClient {
|
|
|
2122
2822
|
let decryptFailed = false;
|
|
2123
2823
|
// V2 P2P thought envelope:per-device wrap,本设备解密自己的 row
|
|
2124
2824
|
if (payload?.type === 'e2ee.p2p_encrypted') {
|
|
2825
|
+
const e2eeObj = v2E2eeMeta(payload);
|
|
2826
|
+
message.e2ee = e2eeObj;
|
|
2125
2827
|
const plaintext = await this._decryptV2EnvelopeForThought({
|
|
2126
2828
|
envelope: payload,
|
|
2127
2829
|
fromAid,
|
|
@@ -2133,7 +2835,6 @@ export class AUNClient {
|
|
|
2133
2835
|
else {
|
|
2134
2836
|
decrypted = { ...message };
|
|
2135
2837
|
decrypted.payload = plaintext;
|
|
2136
|
-
const e2eeObj = v2E2eeMeta(payload);
|
|
2137
2838
|
// 暴露 protected_headers(去 _auth)
|
|
2138
2839
|
const ph = payload.protected_headers;
|
|
2139
2840
|
if (isJsonObject(ph)) {
|
|
@@ -2162,6 +2863,7 @@ export class AUNClient {
|
|
|
2162
2863
|
else if (payload?.type === 'e2ee.encrypted') {
|
|
2163
2864
|
decryptFailed = true;
|
|
2164
2865
|
}
|
|
2866
|
+
const exposedE2ee = (decrypted ?? message).e2ee;
|
|
2165
2867
|
const thought = {
|
|
2166
2868
|
thought_id: thoughtId,
|
|
2167
2869
|
message_id: thoughtId,
|
|
@@ -2169,8 +2871,10 @@ export class AUNClient {
|
|
|
2169
2871
|
to: toAid,
|
|
2170
2872
|
payload: (decrypted ?? message).payload,
|
|
2171
2873
|
created_at: item.created_at,
|
|
2172
|
-
e2ee:
|
|
2874
|
+
e2ee: exposedE2ee,
|
|
2173
2875
|
};
|
|
2876
|
+
if (isJsonObject(exposedE2ee))
|
|
2877
|
+
attachV2EnvelopeMetadata(thought, exposedE2ee);
|
|
2174
2878
|
if (decryptFailed)
|
|
2175
2879
|
thought.decrypt_failed = true;
|
|
2176
2880
|
if ('context' in item)
|
|
@@ -2184,7 +2888,7 @@ export class AUNClient {
|
|
|
2184
2888
|
* 获取对方证书(带缓存 + 完整 PKI 验证:链 + CRL + OCSP + AID 绑定)。
|
|
2185
2889
|
* 跨域时自动将请求路由到 peer 所在域的 Gateway。
|
|
2186
2890
|
*/
|
|
2187
|
-
async _fetchPeerCert(aid, certFingerprint) {
|
|
2891
|
+
async _fetchPeerCert(aid, certFingerprint, timeoutMs = 5000) {
|
|
2188
2892
|
const tStart = Date.now();
|
|
2189
2893
|
this._clientLog.debug(`_fetchPeerCert enter: aid=${aid} fingerprint=${certFingerprint ?? '<none>'}`);
|
|
2190
2894
|
try {
|
|
@@ -2206,7 +2910,7 @@ export class AUNClient {
|
|
|
2206
2910
|
const certUrl = buildCertUrl(peerGatewayUrl, aid, certFingerprint);
|
|
2207
2911
|
// 兼容旧浏览器,不使用 AbortSignal.timeout(Chrome 103+ 才支持)
|
|
2208
2912
|
const controller = new AbortController();
|
|
2209
|
-
const timeoutId = setTimeout(() => controller.abort(),
|
|
2913
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
2210
2914
|
try {
|
|
2211
2915
|
const resp = await fetch(certUrl, { signal: controller.signal });
|
|
2212
2916
|
if (!resp.ok)
|
|
@@ -2223,7 +2927,7 @@ export class AUNClient {
|
|
|
2223
2927
|
}
|
|
2224
2928
|
// 兼容旧浏览器,不使用 AbortSignal.timeout(Chrome 103+ 才支持)
|
|
2225
2929
|
const fallbackController = new AbortController();
|
|
2226
|
-
const fallbackTimeoutId = setTimeout(() => fallbackController.abort(),
|
|
2930
|
+
const fallbackTimeoutId = setTimeout(() => fallbackController.abort(), timeoutMs);
|
|
2227
2931
|
try {
|
|
2228
2932
|
const fallbackResp = await fetch(buildCertUrl(peerGatewayUrl, aid), { signal: fallbackController.signal });
|
|
2229
2933
|
if (!fallbackResp.ok) {
|
|
@@ -2485,6 +3189,10 @@ export class AUNClient {
|
|
|
2485
3189
|
}
|
|
2486
3190
|
}
|
|
2487
3191
|
_resolveGateway(params) {
|
|
3192
|
+
const gateways = this._resolveGateways(params);
|
|
3193
|
+
return gateways[0];
|
|
3194
|
+
}
|
|
3195
|
+
_resolveGateways(params) {
|
|
2488
3196
|
const topology = isJsonObject(params.topology) ? params.topology : null;
|
|
2489
3197
|
if (topology) {
|
|
2490
3198
|
const mode = String(topology.mode ?? 'gateway');
|
|
@@ -2495,10 +3203,16 @@ export class AUNClient {
|
|
|
2495
3203
|
throw new ValidationError('relay topology is not implemented in the Browser SDK');
|
|
2496
3204
|
}
|
|
2497
3205
|
}
|
|
2498
|
-
const
|
|
3206
|
+
const gw = params.gateway ?? params.gateways;
|
|
3207
|
+
if (Array.isArray(gw)) {
|
|
3208
|
+
const urls = gw.map((g) => String(g ?? '')).filter((u) => u.length > 0);
|
|
3209
|
+
if (urls.length > 0)
|
|
3210
|
+
return urls;
|
|
3211
|
+
}
|
|
3212
|
+
const gateway = String(gw ?? this._gatewayUrl ?? '');
|
|
2499
3213
|
if (!gateway)
|
|
2500
3214
|
throw new StateError('missing gateway in connect params');
|
|
2501
|
-
return gateway;
|
|
3215
|
+
return [gateway];
|
|
2502
3216
|
}
|
|
2503
3217
|
async _syncIdentityAfterConnect(accessToken) {
|
|
2504
3218
|
let identity = null;
|
|
@@ -2787,6 +3501,16 @@ export class AUNClient {
|
|
|
2787
3501
|
};
|
|
2788
3502
|
scheduleRefresh(0);
|
|
2789
3503
|
}
|
|
3504
|
+
_normalizeOutboundMessagePayload(params, method = '') {
|
|
3505
|
+
if (!Object.prototype.hasOwnProperty.call(params, 'payload') && Object.prototype.hasOwnProperty.call(params, 'content')) {
|
|
3506
|
+
params.payload = params.content;
|
|
3507
|
+
delete params.content;
|
|
3508
|
+
}
|
|
3509
|
+
const payload = params.payload;
|
|
3510
|
+
if (isJsonObject(payload) && !Object.prototype.hasOwnProperty.call(payload, 'type') && typeof payload.text === 'string') {
|
|
3511
|
+
params.payload = { type: 'text', ...payload };
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
2790
3514
|
_validateMessageRecipient(toAid) {
|
|
2791
3515
|
if (isGroupServiceAid(toAid)) {
|
|
2792
3516
|
throw new ValidationError('message.send receiver cannot be group.{issuer}; use group.send instead');
|
|
@@ -3210,6 +3934,8 @@ export class AUNClient {
|
|
|
3210
3934
|
this._gapFillDone.clear();
|
|
3211
3935
|
this._pushedSeqs.clear();
|
|
3212
3936
|
this._pendingOrderedMsgs.clear();
|
|
3937
|
+
this._v2SenderIKPending.clear();
|
|
3938
|
+
this._v2SenderIKFetching.clear();
|
|
3213
3939
|
this._groupSynced.clear();
|
|
3214
3940
|
}
|
|
3215
3941
|
_refreshSeqTrackerContext() {
|
|
@@ -3220,6 +3946,8 @@ export class AUNClient {
|
|
|
3220
3946
|
this._gapFillDone.clear();
|
|
3221
3947
|
this._pushedSeqs.clear();
|
|
3222
3948
|
this._pendingOrderedMsgs.clear();
|
|
3949
|
+
this._v2SenderIKPending.clear();
|
|
3950
|
+
this._v2SenderIKFetching.clear();
|
|
3223
3951
|
this._groupSynced.clear();
|
|
3224
3952
|
this._seqTrackerContext = nextContext;
|
|
3225
3953
|
}
|
|
@@ -3273,6 +4001,47 @@ export class AUNClient {
|
|
|
3273
4001
|
}).catch(() => { });
|
|
3274
4002
|
}
|
|
3275
4003
|
}
|
|
4004
|
+
_persistRepairedSeq(ns) {
|
|
4005
|
+
if (!this._aid || !ns)
|
|
4006
|
+
return;
|
|
4007
|
+
const seq = this._seqTracker.getContiguousSeq(ns);
|
|
4008
|
+
try {
|
|
4009
|
+
if (seq > 0 && typeof this._keystore.saveSeq === 'function') {
|
|
4010
|
+
this._keystore.saveSeq(this._aid, this._deviceId, this._slotId, ns, seq).catch((exc) => {
|
|
4011
|
+
this._clientLog.debug(`persist repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
|
|
4012
|
+
});
|
|
4013
|
+
return;
|
|
4014
|
+
}
|
|
4015
|
+
const deleteSeq = this._keystore.deleteSeq;
|
|
4016
|
+
if (seq <= 0 && typeof deleteSeq === 'function') {
|
|
4017
|
+
deleteSeq.call(this._keystore, this._aid, this._deviceId, this._slotId, ns).catch((exc) => {
|
|
4018
|
+
this._clientLog.debug(`delete repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
|
|
4019
|
+
});
|
|
4020
|
+
return;
|
|
4021
|
+
}
|
|
4022
|
+
if (seq > 0) {
|
|
4023
|
+
this._saveSeqTrackerState();
|
|
4024
|
+
}
|
|
4025
|
+
}
|
|
4026
|
+
catch (exc) {
|
|
4027
|
+
this._clientLog.debug(`persist repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
|
|
4028
|
+
}
|
|
4029
|
+
}
|
|
4030
|
+
_repairPushContiguousBound(ns, pushSeq, hasPayload, label) {
|
|
4031
|
+
if (!ns || !Number.isFinite(pushSeq) || pushSeq <= 0) {
|
|
4032
|
+
return ns ? this._seqTracker.getContiguousSeq(ns) : 0;
|
|
4033
|
+
}
|
|
4034
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
4035
|
+
const shouldRepair = contig > pushSeq;
|
|
4036
|
+
if (!shouldRepair)
|
|
4037
|
+
return contig;
|
|
4038
|
+
const repairedTo = Math.max(0, pushSeq - 1);
|
|
4039
|
+
this._seqTracker.repairContiguousSeq(ns, repairedTo);
|
|
4040
|
+
const repaired = this._seqTracker.getContiguousSeq(ns);
|
|
4041
|
+
this._persistRepairedSeq(ns);
|
|
4042
|
+
this._clientLog.warn(`${label} push repaired contiguous_seq: ns=${ns} payload=${hasPayload} push_seq=${pushSeq} contiguous=${contig}->${repaired}`);
|
|
4043
|
+
return repaired;
|
|
4044
|
+
}
|
|
3276
4045
|
// ── V2 E2EE API(async,与 Python `client.py` `_init_v2_session` / `send_v2` / `pull_v2` / `ack_v2` 对齐) ──
|
|
3277
4046
|
/**
|
|
3278
4047
|
* 初始化 V2 session:从 AID PEM 私钥提取 raw scalar + DER 公钥,
|
|
@@ -3323,6 +4092,104 @@ export class AUNClient {
|
|
|
3323
4092
|
// 上线时自动确认 pending state proposals
|
|
3324
4093
|
this._safeAsync(this._v2AutoConfirmPendingProposals());
|
|
3325
4094
|
}
|
|
4095
|
+
async _v2TrustedIKPubDer(aid) {
|
|
4096
|
+
const normalizedAid = String(aid ?? '').trim();
|
|
4097
|
+
if (!normalizedAid)
|
|
4098
|
+
throw new E2EEError('spk_aid_missing');
|
|
4099
|
+
if (this._aid && normalizedAid === this._aid) {
|
|
4100
|
+
if (!this._v2Session)
|
|
4101
|
+
throw new E2EEError('V2 session not initialized');
|
|
4102
|
+
return this._v2Session.currentIkPubDer;
|
|
4103
|
+
}
|
|
4104
|
+
const certPem = await this._fetchPeerCert(normalizedAid);
|
|
4105
|
+
const pubKey = await importCertPublicKeyEcdsa(certPem);
|
|
4106
|
+
return new Uint8Array(await crypto.subtle.exportKey('spki', pubKey));
|
|
4107
|
+
}
|
|
4108
|
+
_v2SPKTimestampText(value, aid, deviceId, spkId) {
|
|
4109
|
+
if (value === null || value === undefined || value === '') {
|
|
4110
|
+
throw new E2EEError(`spk_timestamp_missing: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
|
|
4111
|
+
}
|
|
4112
|
+
if (typeof value === 'boolean') {
|
|
4113
|
+
throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
|
|
4114
|
+
}
|
|
4115
|
+
if (typeof value === 'number') {
|
|
4116
|
+
if (!Number.isSafeInteger(value)) {
|
|
4117
|
+
throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
|
|
4118
|
+
}
|
|
4119
|
+
return String(value);
|
|
4120
|
+
}
|
|
4121
|
+
const text = String(value).trim();
|
|
4122
|
+
if (!/^\d+$/.test(text)) {
|
|
4123
|
+
throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
|
|
4124
|
+
}
|
|
4125
|
+
return BigInt(text).toString();
|
|
4126
|
+
}
|
|
4127
|
+
async _v2VerifySPKDevice(args) {
|
|
4128
|
+
if (!this._v2Session)
|
|
4129
|
+
throw new E2EEError('V2 session not initialized');
|
|
4130
|
+
const spkId = String(args.dev.spk_id ?? '').trim();
|
|
4131
|
+
if (!spkId)
|
|
4132
|
+
return;
|
|
4133
|
+
if (args.keySource !== 'peer_device_prekey' && args.keySource !== 'group_device_prekey') {
|
|
4134
|
+
throw new E2EEError(`spk_key_source_invalid: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId} key_source=${args.keySource}`);
|
|
4135
|
+
}
|
|
4136
|
+
if (!args.spkPkDer || args.spkPkDer.length === 0) {
|
|
4137
|
+
throw new E2EEError(`spk_public_key_missing: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
4138
|
+
}
|
|
4139
|
+
const spkHash = bytesToHex(new Uint8Array(await crypto.subtle.digest('SHA-256', args.spkPkDer.slice().buffer)));
|
|
4140
|
+
const expectedSpkId = `sha256:${spkHash.substring(0, 16)}`;
|
|
4141
|
+
if (spkId !== expectedSpkId) {
|
|
4142
|
+
throw new E2EEError(`spk_id_mismatch: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId} expected=${expectedSpkId}`);
|
|
4143
|
+
}
|
|
4144
|
+
const trustedIK = await this._v2TrustedIKPubDer(args.aid);
|
|
4145
|
+
if (!_v2BytesEqual(trustedIK, args.ikPkDer)) {
|
|
4146
|
+
throw new E2EEError(`spk_ik_mismatch: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
4147
|
+
}
|
|
4148
|
+
if (_v2BytesEqual(args.spkPkDer, trustedIK)) {
|
|
4149
|
+
this._v2Session.markPeerSPKVerified(args.aid, args.deviceId, spkId);
|
|
4150
|
+
return;
|
|
4151
|
+
}
|
|
4152
|
+
const sigB64 = String(args.dev.spk_signature ?? '').trim();
|
|
4153
|
+
if (!sigB64) {
|
|
4154
|
+
throw new E2EEError(`spk_signature_missing: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
4155
|
+
}
|
|
4156
|
+
let signature;
|
|
4157
|
+
try {
|
|
4158
|
+
signature = _v2B64ToBytesStrict(sigB64);
|
|
4159
|
+
}
|
|
4160
|
+
catch {
|
|
4161
|
+
throw new E2EEError(`spk_signature_invalid_base64: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
4162
|
+
}
|
|
4163
|
+
const encoder = new TextEncoder();
|
|
4164
|
+
const tsText = this._v2SPKTimestampText(args.dev.spk_timestamp, args.aid, args.deviceId, spkId);
|
|
4165
|
+
const signData = _v2ConcatBytes(args.spkPkDer, encoder.encode(spkId), encoder.encode(tsText));
|
|
4166
|
+
if (!(await ecdsaVerifyRaw(trustedIK, signature, signData))) {
|
|
4167
|
+
throw new E2EEError(`spk_signature_invalid: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
4168
|
+
}
|
|
4169
|
+
this._v2Session.markPeerSPKVerified(args.aid, args.deviceId, spkId);
|
|
4170
|
+
}
|
|
4171
|
+
async _v2BuildTargetFromDevice(args) {
|
|
4172
|
+
const aid = String(args.aid ?? '').trim();
|
|
4173
|
+
const devId = getV2DeviceId(args.dev);
|
|
4174
|
+
const deviceId = devId.present ? devId.value : String(args.deviceId ?? '').trim();
|
|
4175
|
+
const ikPk = String(args.dev.ik_pk ?? '').trim();
|
|
4176
|
+
if (!aid || !devId.present || !ikPk)
|
|
4177
|
+
return null;
|
|
4178
|
+
const ikPkDer = _v2B64ToBytes(ikPk);
|
|
4179
|
+
const spkPkDer = args.dev.spk_pk ? _v2B64ToBytes(String(args.dev.spk_pk)) : undefined;
|
|
4180
|
+
const keySource = String(args.dev.key_source ?? args.defaultKeySource).trim() || args.defaultKeySource;
|
|
4181
|
+
await this._v2VerifySPKDevice({ dev: args.dev, aid, deviceId, ikPkDer, spkPkDer, keySource });
|
|
4182
|
+
this._v2Session?.cachePeerIK(aid, deviceId, ikPkDer);
|
|
4183
|
+
return {
|
|
4184
|
+
aid,
|
|
4185
|
+
deviceId,
|
|
4186
|
+
role: args.role,
|
|
4187
|
+
keySource,
|
|
4188
|
+
ikPkDer,
|
|
4189
|
+
spkPkDer,
|
|
4190
|
+
spkId: String(args.dev.spk_id ?? '').trim(),
|
|
4191
|
+
};
|
|
4192
|
+
}
|
|
3326
4193
|
async _getV2SenderPubDer(fromAid, senderDeviceId) {
|
|
3327
4194
|
const session = this._v2Session;
|
|
3328
4195
|
if (!session || !fromAid)
|
|
@@ -3331,37 +4198,128 @@ export class AUNClient {
|
|
|
3331
4198
|
if (senderPubDer)
|
|
3332
4199
|
return senderPubDer;
|
|
3333
4200
|
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);
|
|
4201
|
+
const certPem = await this._fetchPeerCert(fromAid, undefined, 3000);
|
|
3352
4202
|
const pubKey = await importCertPublicKeyEcdsa(certPem);
|
|
3353
4203
|
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}`);
|
|
4204
|
+
session.cachePeerIK(fromAid, senderDeviceId, senderPubDer);
|
|
4205
|
+
this._clientLog.debug(`V2 decrypt: sender IK fallback from PKI cert for ${fromAid}`);
|
|
3358
4206
|
return senderPubDer;
|
|
3359
4207
|
}
|
|
3360
4208
|
catch (exc) {
|
|
3361
|
-
this._clientLog.warn(`V2 decrypt:
|
|
4209
|
+
this._clientLog.warn(`V2 decrypt: PKI cert sender IK fallback failed for ${fromAid}: ${String(formatCaughtError(exc))}`);
|
|
3362
4210
|
return null;
|
|
3363
4211
|
}
|
|
3364
4212
|
}
|
|
4213
|
+
_v2PendingSenderIKMessageKey(msg, groupId) {
|
|
4214
|
+
const messageId = String(msg.message_id ?? '').trim();
|
|
4215
|
+
const seq = String(msg.seq ?? '').trim();
|
|
4216
|
+
const prefix = groupId ? `group:${groupId}` : `p2p:${this._aid ?? ''}`;
|
|
4217
|
+
return `${prefix}:${messageId || seq || Math.random().toString(36).slice(2)}`;
|
|
4218
|
+
}
|
|
4219
|
+
_v2PendingSenderIKFetchKey(fromAid, senderDeviceId, groupId) {
|
|
4220
|
+
return `${fromAid}#${senderDeviceId}#${groupId || ''}`;
|
|
4221
|
+
}
|
|
4222
|
+
_cacheV2PeerIKFromDevice(dev, fallbackAid = '') {
|
|
4223
|
+
const session = this._v2Session;
|
|
4224
|
+
if (!session || !isJsonObject(dev))
|
|
4225
|
+
return;
|
|
4226
|
+
const device = dev;
|
|
4227
|
+
const devId = getV2DeviceId(device);
|
|
4228
|
+
const aid = String(device.aid ?? fallbackAid ?? '').trim();
|
|
4229
|
+
const ikPk = String(device.ik_pk ?? '').trim();
|
|
4230
|
+
if (!devId.present || !aid || !ikPk)
|
|
4231
|
+
return;
|
|
4232
|
+
try {
|
|
4233
|
+
session.cachePeerIK(aid, devId.value, _v2B64ToBytes(ikPk));
|
|
4234
|
+
}
|
|
4235
|
+
catch (exc) {
|
|
4236
|
+
this._clientLog.debug(`V2 sender IK cache from bootstrap skipped aid=${aid} dev=${devId.value}: ${String(formatCaughtError(exc))}`);
|
|
4237
|
+
}
|
|
4238
|
+
}
|
|
4239
|
+
_scheduleV2SenderIKPending(args) {
|
|
4240
|
+
const fromAid = String(args.fromAid ?? '').trim();
|
|
4241
|
+
if (!fromAid)
|
|
4242
|
+
return;
|
|
4243
|
+
const senderDeviceId = String(args.senderDeviceId ?? '');
|
|
4244
|
+
const groupId = String(args.groupId ?? '').trim();
|
|
4245
|
+
const messageKey = this._v2PendingSenderIKMessageKey(args.msg, groupId);
|
|
4246
|
+
this._v2SenderIKPending.set(messageKey, {
|
|
4247
|
+
msg: { ...args.msg },
|
|
4248
|
+
fromAid,
|
|
4249
|
+
senderDeviceId,
|
|
4250
|
+
groupId,
|
|
4251
|
+
createdAt: Date.now(),
|
|
4252
|
+
});
|
|
4253
|
+
this._clientLog.debug(`V2 decrypt pending sender IK: key=${messageKey} from=${fromAid} device=${senderDeviceId || '-'} group=${groupId || '<p2p>'} pending=${this._v2SenderIKPending.size}`);
|
|
4254
|
+
this._scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupId);
|
|
4255
|
+
}
|
|
4256
|
+
_scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupId) {
|
|
4257
|
+
const fetchKey = this._v2PendingSenderIKFetchKey(fromAid, senderDeviceId, groupId);
|
|
4258
|
+
if (!fromAid || this._v2SenderIKFetching.has(fetchKey))
|
|
4259
|
+
return;
|
|
4260
|
+
this._v2SenderIKFetching.add(fetchKey);
|
|
4261
|
+
this._safeAsync(this._resolveV2SenderIKPending(fromAid, senderDeviceId, groupId, fetchKey));
|
|
4262
|
+
}
|
|
4263
|
+
async _resolveV2SenderIKPending(fromAid, senderDeviceId, groupId, fetchKey) {
|
|
4264
|
+
try {
|
|
4265
|
+
const session = this._v2Session;
|
|
4266
|
+
if (session && fromAid) {
|
|
4267
|
+
try {
|
|
4268
|
+
const bs = await this.call('message.v2.bootstrap', { peer_aid: fromAid });
|
|
4269
|
+
const peers = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
|
|
4270
|
+
for (const dev of peers)
|
|
4271
|
+
this._cacheV2PeerIKFromDevice(dev, fromAid);
|
|
4272
|
+
}
|
|
4273
|
+
catch (exc) {
|
|
4274
|
+
this._clientLog.warn(`V2 sender IK pending bootstrap failed peer=${fromAid}: ${String(formatCaughtError(exc))}`);
|
|
4275
|
+
}
|
|
4276
|
+
if (groupId) {
|
|
4277
|
+
try {
|
|
4278
|
+
const gbs = await this.call('group.v2.bootstrap', { group_id: groupId });
|
|
4279
|
+
const devices = (Array.isArray(gbs?.devices) ? gbs.devices : []);
|
|
4280
|
+
const audit = (Array.isArray(gbs?.audit_recipients) ? gbs.audit_recipients : []);
|
|
4281
|
+
for (const dev of devices)
|
|
4282
|
+
this._cacheV2PeerIKFromDevice(dev);
|
|
4283
|
+
for (const dev of audit)
|
|
4284
|
+
this._cacheV2PeerIKFromDevice(dev);
|
|
4285
|
+
}
|
|
4286
|
+
catch (exc) {
|
|
4287
|
+
this._clientLog.warn(`V2 sender IK pending group bootstrap failed group=${groupId}: ${String(formatCaughtError(exc))}`);
|
|
4288
|
+
}
|
|
4289
|
+
}
|
|
4290
|
+
if (!session.getPeerIK(fromAid, senderDeviceId)) {
|
|
4291
|
+
await this._getV2SenderPubDer(fromAid, senderDeviceId);
|
|
4292
|
+
}
|
|
4293
|
+
}
|
|
4294
|
+
const pendingItems = [...this._v2SenderIKPending.entries()].filter(([, entry]) => entry.fromAid === fromAid && entry.senderDeviceId === senderDeviceId && entry.groupId === groupId);
|
|
4295
|
+
for (const [key, entry] of pendingItems) {
|
|
4296
|
+
let plaintext = null;
|
|
4297
|
+
try {
|
|
4298
|
+
plaintext = await this._decryptV2Message(entry.msg, false);
|
|
4299
|
+
}
|
|
4300
|
+
catch (exc) {
|
|
4301
|
+
this._clientLog.warn(`V2 sender IK pending retry raised: key=${key} err=${String(formatCaughtError(exc))}`);
|
|
4302
|
+
}
|
|
4303
|
+
this._v2SenderIKPending.delete(key);
|
|
4304
|
+
if (plaintext === null) {
|
|
4305
|
+
this._clientLog.debug(`V2 sender IK pending retry failed: key=${key}`);
|
|
4306
|
+
continue;
|
|
4307
|
+
}
|
|
4308
|
+
const seq = Number(entry.msg.seq ?? 0);
|
|
4309
|
+
if (entry.groupId) {
|
|
4310
|
+
plaintext.group_id = entry.groupId;
|
|
4311
|
+
await this._publishPulledMessage('group.message_created', `group:${entry.groupId}`, seq, plaintext);
|
|
4312
|
+
}
|
|
4313
|
+
else {
|
|
4314
|
+
await this._publishPulledMessage('message.received', `p2p:${this._aid ?? ''}`, seq, plaintext);
|
|
4315
|
+
}
|
|
4316
|
+
this._clientLog.debug(`V2 sender IK pending retry delivered: key=${key}`);
|
|
4317
|
+
}
|
|
4318
|
+
}
|
|
4319
|
+
finally {
|
|
4320
|
+
this._v2SenderIKFetching.delete(fetchKey);
|
|
4321
|
+
}
|
|
4322
|
+
}
|
|
3365
4323
|
/**
|
|
3366
4324
|
* V2 P2P 加密发送(推测性:用缓存 bootstrap 直接发,失败刷新重试一次)。
|
|
3367
4325
|
*
|
|
@@ -3374,108 +4332,21 @@ export class AUNClient {
|
|
|
3374
4332
|
if (!this._v2Session) {
|
|
3375
4333
|
throw new StateError('V2 session not initialized (not connected?)');
|
|
3376
4334
|
}
|
|
3377
|
-
const session = this._v2Session;
|
|
3378
4335
|
const toAid = String(to ?? '').trim();
|
|
3379
4336
|
if (!toAid)
|
|
3380
4337
|
throw new ValidationError("message.send requires 'to'");
|
|
3381
4338
|
if (!isJsonObject(payload))
|
|
3382
4339
|
throw new ValidationError('message.send payload must be a dict for V2 encryption');
|
|
3383
4340
|
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 ?? {});
|
|
4341
|
+
const envelope = await this._buildV2P2PEnvelope({
|
|
4342
|
+
to: toAid,
|
|
4343
|
+
payload,
|
|
4344
|
+
messageId: opts?.messageId,
|
|
4345
|
+
timestamp: opts?.timestamp,
|
|
4346
|
+
protectedHeaders: opts?.protectedHeaders,
|
|
4347
|
+
context: opts?.context,
|
|
4348
|
+
useCache,
|
|
4349
|
+
});
|
|
3479
4350
|
return this.call('message.send', {
|
|
3480
4351
|
to: toAid,
|
|
3481
4352
|
payload: envelope,
|
|
@@ -3506,91 +4377,106 @@ export class AUNClient {
|
|
|
3506
4377
|
throw new StateError('V2 session not initialized (not connected?)');
|
|
3507
4378
|
}
|
|
3508
4379
|
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
4380
|
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
|
-
|
|
4381
|
+
let nextAfterSeq = afterSeq || (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
|
|
4382
|
+
let pageCount = 0;
|
|
4383
|
+
const maxPages = 100;
|
|
4384
|
+
while (pageCount < maxPages) {
|
|
4385
|
+
pageCount += 1;
|
|
4386
|
+
const result = await this.call('message.v2.pull', {
|
|
4387
|
+
after_seq: nextAfterSeq,
|
|
4388
|
+
limit,
|
|
4389
|
+
});
|
|
4390
|
+
const messages = (Array.isArray(result?.messages) ? result.messages : []);
|
|
4391
|
+
const seqs = messages
|
|
4392
|
+
.map((msg) => Number(msg.seq ?? 0))
|
|
4393
|
+
.filter((seq) => Number.isFinite(seq) && seq > 0);
|
|
4394
|
+
const pageContigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
|
|
4395
|
+
const pageMaxSeq = seqs.length > 0 ? Math.max(...seqs) : nextAfterSeq;
|
|
4396
|
+
if (ns && seqs.length > 0) {
|
|
4397
|
+
this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
|
|
4398
|
+
}
|
|
4399
|
+
for (const msg of messages) {
|
|
4400
|
+
const seq = Number(msg.seq ?? 0);
|
|
4401
|
+
if (!Number.isFinite(seq) || seq <= 0)
|
|
4402
|
+
continue;
|
|
4403
|
+
const version = String(msg.version ?? 'v2');
|
|
4404
|
+
if (version === 'v1') {
|
|
4405
|
+
const legacy = isJsonObject(msg.legacy_v1) ? msg.legacy_v1 : {};
|
|
4406
|
+
const legacyPayload = legacy.payload;
|
|
4407
|
+
const payloadType = isJsonObject(legacyPayload)
|
|
4408
|
+
? String(legacyPayload.type ?? '').trim()
|
|
4409
|
+
: '';
|
|
4410
|
+
if (legacyPayload !== undefined && legacyPayload !== null
|
|
4411
|
+
&& payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
|
|
4412
|
+
const v1Msg = {
|
|
4413
|
+
message_id: String(msg.message_id ?? ''),
|
|
4414
|
+
from: String(msg.from_aid ?? ''),
|
|
4415
|
+
to: String(legacy.to ?? this._aid ?? ''),
|
|
4416
|
+
seq: msg.seq,
|
|
4417
|
+
type: String(msg.type ?? ''),
|
|
4418
|
+
timestamp: msg.t_server,
|
|
4419
|
+
payload: legacyPayload,
|
|
4420
|
+
encrypted: false,
|
|
4421
|
+
};
|
|
4422
|
+
if (ns)
|
|
4423
|
+
await this._publishPulledMessage('message.received', ns, seq, v1Msg);
|
|
4424
|
+
else
|
|
4425
|
+
await this._publishAppEvent('message.received', v1Msg);
|
|
4426
|
+
decrypted.push(v1Msg);
|
|
4427
|
+
}
|
|
4428
|
+
else {
|
|
4429
|
+
this._clientLog.debug(`message.v2.pull skipping V1 envelope seq=${seq} payload_type=${payloadType || '<none>'} (V1 E2EE removed)`);
|
|
4430
|
+
}
|
|
4431
|
+
continue;
|
|
4432
|
+
}
|
|
4433
|
+
if (version !== 'v2') {
|
|
4434
|
+
this._clientLog.debug(`message.v2.pull skipping non-V2 row seq=${seq} version=${String(msg.version ?? '')}`);
|
|
4435
|
+
continue;
|
|
4436
|
+
}
|
|
4437
|
+
// 跟踪每个旧 SPK 引用的最大 seq(用于消费后销毁)
|
|
4438
|
+
const msgSpkId = String(msg.spk_id ?? '');
|
|
4439
|
+
if (msgSpkId && this._v2Session && !this._v2Session.isCurrentSPK(msgSpkId)) {
|
|
4440
|
+
this._v2Session.trackOldSPKMaxSeq(msgSpkId, seq);
|
|
4441
|
+
}
|
|
4442
|
+
const plaintext = await this._decryptV2Message(msg);
|
|
4443
|
+
if (plaintext === null)
|
|
4444
|
+
continue;
|
|
4445
|
+
if (ns) {
|
|
4446
|
+
await this._publishPulledMessage('message.received', ns, seq, plaintext);
|
|
4447
|
+
decrypted.push(plaintext);
|
|
3551
4448
|
}
|
|
3552
4449
|
else {
|
|
3553
|
-
this.
|
|
4450
|
+
await this._publishAppEvent('message.received', plaintext);
|
|
4451
|
+
decrypted.push(plaintext);
|
|
3554
4452
|
}
|
|
3555
|
-
continue;
|
|
3556
|
-
}
|
|
3557
|
-
if (version !== 'v2') {
|
|
3558
|
-
this._clientLog.debug(`message.v2.pull skipping non-V2 row seq=${seq} version=${String(msg.version ?? '')}`);
|
|
3559
|
-
continue;
|
|
3560
4453
|
}
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
4454
|
+
const serverAckSeq = Number(result.server_ack_seq ?? 0);
|
|
4455
|
+
if (ns && Number.isFinite(serverAckSeq) && serverAckSeq > 0) {
|
|
4456
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
4457
|
+
if (contig < serverAckSeq) {
|
|
4458
|
+
this._clientLog.info(`message.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> server_ack_seq=${serverAckSeq}`);
|
|
4459
|
+
this._seqTracker.forceContiguousSeq(ns, serverAckSeq);
|
|
4460
|
+
}
|
|
3565
4461
|
}
|
|
3566
|
-
// 解密
|
|
3567
|
-
const plaintext = await this._decryptV2Message(msg);
|
|
3568
|
-
if (plaintext === null)
|
|
3569
|
-
continue;
|
|
3570
|
-
// 有序 publish + 去重(与 V1 push 路径对齐)
|
|
3571
4462
|
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) {
|
|
4463
|
+
const ackSeq = this._seqTracker.getContiguousSeq(ns);
|
|
4464
|
+
const contigAdvanced = ackSeq !== pageContigBefore;
|
|
4465
|
+
if (contigAdvanced) {
|
|
4466
|
+
await this._drainOrderedMessages(ns);
|
|
4467
|
+
this._saveSeqTrackerState();
|
|
4468
|
+
}
|
|
4469
|
+
if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
|
|
3591
4470
|
this._safeAsync(this.ackV2(ackSeq).then(() => undefined));
|
|
3592
4471
|
}
|
|
3593
4472
|
}
|
|
4473
|
+
const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
|
|
4474
|
+
if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
|
|
4475
|
+
break;
|
|
4476
|
+
nextAfterSeq = nextAfter;
|
|
4477
|
+
}
|
|
4478
|
+
if (pageCount >= maxPages) {
|
|
4479
|
+
this._clientLog.warn(`message.v2.pull reached max_pages=${maxPages} after_seq=${nextAfterSeq}`);
|
|
3594
4480
|
}
|
|
3595
4481
|
return decrypted;
|
|
3596
4482
|
}
|
|
@@ -3601,9 +4487,17 @@ export class AUNClient {
|
|
|
3601
4487
|
*/
|
|
3602
4488
|
async ackV2(upToSeq) {
|
|
3603
4489
|
const ns = this._aid ? `p2p:${this._aid}` : '';
|
|
3604
|
-
|
|
4490
|
+
let seq = upToSeq ?? (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
|
|
3605
4491
|
if (seq <= 0)
|
|
3606
4492
|
return { acked: 0 };
|
|
4493
|
+
// ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
|
|
4494
|
+
if (ns) {
|
|
4495
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
4496
|
+
if (maxSeen > 0 && seq > maxSeen) {
|
|
4497
|
+
this._clientLog.warn(`ackV2 clamp: up_to_seq=${seq} > max_seen=${maxSeen}, clamp`);
|
|
4498
|
+
seq = maxSeen;
|
|
4499
|
+
}
|
|
4500
|
+
}
|
|
3607
4501
|
const raw = await this.call('message.v2.ack', { up_to_seq: seq });
|
|
3608
4502
|
const result = isJsonObject(raw)
|
|
3609
4503
|
? { ...raw }
|
|
@@ -3634,8 +4528,8 @@ export class AUNClient {
|
|
|
3634
4528
|
}
|
|
3635
4529
|
return result;
|
|
3636
4530
|
}
|
|
3637
|
-
/** 解密单条 V2 消息(与 Python `_decrypt_v2_message`
|
|
3638
|
-
async _decryptV2Message(msg) {
|
|
4531
|
+
/** 解密单条 V2 消息(与 Python `_decrypt_v2_message` 对齐)。缺 sender IK 时先入 pending,后台补齐后重试。 */
|
|
4532
|
+
async _decryptV2Message(msg, allowPending = true) {
|
|
3639
4533
|
const session = this._v2Session;
|
|
3640
4534
|
if (!session)
|
|
3641
4535
|
return null;
|
|
@@ -3650,6 +4544,8 @@ export class AUNClient {
|
|
|
3650
4544
|
this._clientLog.warn(`V2 decrypt: invalid envelope_json for msg seq=${String(msg.seq)}`);
|
|
3651
4545
|
return null;
|
|
3652
4546
|
}
|
|
4547
|
+
const e2eeMeta = v2E2eeMeta(envelope);
|
|
4548
|
+
await this._observeAgentMdFromEnvelope(envelope);
|
|
3653
4549
|
// 确定 spk_id 和 recipient_key_source
|
|
3654
4550
|
let spkId = '';
|
|
3655
4551
|
let recipientKeySource = '';
|
|
@@ -3679,33 +4575,73 @@ export class AUNClient {
|
|
|
3679
4575
|
}
|
|
3680
4576
|
}
|
|
3681
4577
|
}
|
|
3682
|
-
//
|
|
4578
|
+
// group_id 只表示群上下文;getGroupDecryptKeys 内部必须按 group SPK -> P2P device SPK -> IK fallback 查找。
|
|
3683
4579
|
const aad = isJsonObject(envelope.aad) ? envelope.aad : {};
|
|
3684
4580
|
const groupIdForKeys = String(msg.group_id ?? aad.group_id ?? envelope.group_id ?? '').trim();
|
|
4581
|
+
const undecryptableEvent = groupIdForKeys ? 'group.message_undecryptable' : 'message.undecryptable';
|
|
3685
4582
|
let ikPriv;
|
|
3686
4583
|
let spkPriv;
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
4584
|
+
try {
|
|
4585
|
+
if (groupIdForKeys) {
|
|
4586
|
+
const keys = await session.getGroupDecryptKeys(groupIdForKeys, spkId);
|
|
4587
|
+
ikPriv = keys.ikPriv;
|
|
4588
|
+
spkPriv = keys.spkPriv;
|
|
4589
|
+
}
|
|
4590
|
+
else {
|
|
4591
|
+
const keys = await session.getDecryptKeys(spkId);
|
|
4592
|
+
ikPriv = keys.ikPriv;
|
|
4593
|
+
spkPriv = keys.spkPriv;
|
|
4594
|
+
}
|
|
3691
4595
|
}
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
4596
|
+
catch (exc) {
|
|
4597
|
+
this._clientLog.warn(`V2 decrypt: SPK lookup failed seq=${String(msg.seq)} spk_id=${spkId}: ${String(exc)}`);
|
|
4598
|
+
try {
|
|
4599
|
+
const event = {
|
|
4600
|
+
message_id: String(msg.message_id ?? ''),
|
|
4601
|
+
from: String(msg.from_aid ?? ''),
|
|
4602
|
+
to: String(msg.to ?? ''),
|
|
4603
|
+
seq: msg.seq,
|
|
4604
|
+
timestamp: (msg.t_server ?? msg.timestamp),
|
|
4605
|
+
device_id: String(msg.device_id ?? ''),
|
|
4606
|
+
slot_id: String(msg.slot_id ?? ''),
|
|
4607
|
+
_decrypt_error: String(exc),
|
|
4608
|
+
_decrypt_stage: 'spk_lookup',
|
|
4609
|
+
_envelope_type: String(envelope.type ?? ''),
|
|
4610
|
+
_suite: String(envelope.suite ?? ''),
|
|
4611
|
+
_spk_id: spkId,
|
|
4612
|
+
};
|
|
4613
|
+
attachV2EnvelopeMetadata(event, e2eeMeta);
|
|
4614
|
+
await this._dispatcher.publish(undecryptableEvent, event);
|
|
4615
|
+
}
|
|
4616
|
+
catch { /* publish 异常不影响主流程 */ }
|
|
4617
|
+
return null;
|
|
3696
4618
|
}
|
|
3697
4619
|
const fromAid = String(msg.from_aid ?? '');
|
|
3698
4620
|
const senderDeviceId = String(aad.from_device ?? '');
|
|
3699
4621
|
const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId);
|
|
3700
4622
|
if (!senderPubDer) {
|
|
3701
|
-
this._clientLog.warn(`V2 decrypt: no sender IK for ${fromAid}
|
|
4623
|
+
this._clientLog.warn(`V2 decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
|
|
4624
|
+
if (allowPending) {
|
|
4625
|
+
this._scheduleV2SenderIKPending({ msg, fromAid, senderDeviceId, groupId: groupIdForKeys });
|
|
4626
|
+
return null;
|
|
4627
|
+
}
|
|
3702
4628
|
try {
|
|
3703
|
-
|
|
4629
|
+
const event = {
|
|
3704
4630
|
message_id: String(msg.message_id ?? ''),
|
|
3705
4631
|
from: fromAid,
|
|
4632
|
+
to: String(msg.to ?? ''),
|
|
3706
4633
|
seq: msg.seq,
|
|
4634
|
+
timestamp: (msg.t_server ?? msg.timestamp),
|
|
4635
|
+
device_id: String(msg.device_id ?? ''),
|
|
4636
|
+
slot_id: String(msg.slot_id ?? ''),
|
|
3707
4637
|
_decrypt_error: 'sender_ik_not_found',
|
|
3708
|
-
|
|
4638
|
+
_decrypt_stage: 'sender_ik',
|
|
4639
|
+
_envelope_type: String(envelope.type ?? ''),
|
|
4640
|
+
_suite: String(envelope.suite ?? ''),
|
|
4641
|
+
_sender_device_id: String(aad.from_device ?? ''),
|
|
4642
|
+
};
|
|
4643
|
+
attachV2EnvelopeMetadata(event, e2eeMeta);
|
|
4644
|
+
await this._dispatcher.publish(undecryptableEvent, event);
|
|
3709
4645
|
}
|
|
3710
4646
|
catch { /* publish 异常不影响主流程 */ }
|
|
3711
4647
|
return null;
|
|
@@ -3717,12 +4653,22 @@ export class AUNClient {
|
|
|
3717
4653
|
catch (exc) {
|
|
3718
4654
|
this._clientLog.warn(`V2 decrypt failed for msg seq=${String(msg.seq)}: ${String(exc)}`);
|
|
3719
4655
|
try {
|
|
3720
|
-
|
|
4656
|
+
const event = {
|
|
3721
4657
|
message_id: String(msg.message_id ?? ''),
|
|
3722
4658
|
from: fromAid,
|
|
4659
|
+
to: String(msg.to ?? ''),
|
|
3723
4660
|
seq: msg.seq,
|
|
4661
|
+
timestamp: (msg.t_server ?? msg.timestamp),
|
|
4662
|
+
device_id: String(msg.device_id ?? ''),
|
|
4663
|
+
slot_id: String(msg.slot_id ?? ''),
|
|
3724
4664
|
_decrypt_error: String(exc),
|
|
3725
|
-
|
|
4665
|
+
_decrypt_stage: 'decrypt',
|
|
4666
|
+
_envelope_type: String(envelope.type ?? ''),
|
|
4667
|
+
_suite: String(envelope.suite ?? ''),
|
|
4668
|
+
_sender_device_id: String(aad.from_device ?? ''),
|
|
4669
|
+
};
|
|
4670
|
+
attachV2EnvelopeMetadata(event, e2eeMeta);
|
|
4671
|
+
await this._dispatcher.publish(undecryptableEvent, event);
|
|
3726
4672
|
}
|
|
3727
4673
|
catch { /* publish 异常不影响主流程 */ }
|
|
3728
4674
|
return null;
|
|
@@ -3748,7 +4694,8 @@ export class AUNClient {
|
|
|
3748
4694
|
this._clientLog.debug(`V2 SPK rotation failed (non-fatal): ${exc}`);
|
|
3749
4695
|
});
|
|
3750
4696
|
}
|
|
3751
|
-
|
|
4697
|
+
const e2ee = v2E2eeMeta(envelope);
|
|
4698
|
+
const result = {
|
|
3752
4699
|
message_id: String(msg.message_id ?? ''),
|
|
3753
4700
|
from: fromAid,
|
|
3754
4701
|
to: this._aid ?? '',
|
|
@@ -3756,8 +4703,10 @@ export class AUNClient {
|
|
|
3756
4703
|
t_server: msg.t_server,
|
|
3757
4704
|
payload: plaintext,
|
|
3758
4705
|
encrypted: true,
|
|
3759
|
-
e2ee:
|
|
4706
|
+
e2ee: e2ee,
|
|
3760
4707
|
};
|
|
4708
|
+
attachV2EnvelopeMetadata(result, e2ee);
|
|
4709
|
+
return result;
|
|
3761
4710
|
}
|
|
3762
4711
|
/**
|
|
3763
4712
|
* V2 Group 加密发送(推测性:用缓存 bootstrap 直接发,失败刷新重试一次)。
|
|
@@ -3843,32 +4792,53 @@ export class AUNClient {
|
|
|
3843
4792
|
if (!gid)
|
|
3844
4793
|
throw new ValidationError('group.pull requires group_id');
|
|
3845
4794
|
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
4795
|
const decrypted = [];
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
this.
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
const
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
4796
|
+
let nextAfterSeq = afterSeq || this._seqTracker.getContiguousSeq(ns);
|
|
4797
|
+
let pageCount = 0;
|
|
4798
|
+
const maxPages = 100;
|
|
4799
|
+
while (pageCount < maxPages) {
|
|
4800
|
+
pageCount += 1;
|
|
4801
|
+
const result = await this.call('group.v2.pull', {
|
|
4802
|
+
group_id: gid,
|
|
4803
|
+
after_seq: nextAfterSeq,
|
|
4804
|
+
limit,
|
|
4805
|
+
});
|
|
4806
|
+
const messages = (Array.isArray(result?.messages) ? result.messages : []);
|
|
4807
|
+
const seqs = messages
|
|
4808
|
+
.map((msg) => Number(msg.seq ?? 0))
|
|
4809
|
+
.filter((seq) => Number.isFinite(seq) && seq > 0);
|
|
4810
|
+
const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
4811
|
+
const pageMaxSeq = seqs.length > 0 ? Math.max(...seqs) : nextAfterSeq;
|
|
4812
|
+
if (seqs.length > 0) {
|
|
4813
|
+
this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
|
|
4814
|
+
}
|
|
4815
|
+
for (const msg of messages) {
|
|
4816
|
+
const seq = Number(msg.seq ?? 0);
|
|
4817
|
+
if (!Number.isFinite(seq) || seq <= 0)
|
|
4818
|
+
continue;
|
|
4819
|
+
const version = String(msg.version ?? 'v2');
|
|
4820
|
+
if (version === 'v1') {
|
|
4821
|
+
const payload = msg.payload;
|
|
4822
|
+
const payloadObj = isJsonObject(payload) ? payload : null;
|
|
4823
|
+
if (payloadObj) {
|
|
4824
|
+
const payloadType = String(payloadObj.type ?? '').trim();
|
|
4825
|
+
if (payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
|
|
4826
|
+
const v1Msg = {
|
|
4827
|
+
message_id: String(msg.message_id ?? ''),
|
|
4828
|
+
from: String(msg.from_aid ?? ''),
|
|
4829
|
+
group_id: gid,
|
|
4830
|
+
seq: msg.seq,
|
|
4831
|
+
type: String(msg.type ?? ''),
|
|
4832
|
+
timestamp: msg.t_server,
|
|
4833
|
+
payload,
|
|
4834
|
+
encrypted: false,
|
|
4835
|
+
};
|
|
4836
|
+
await this._publishPulledMessage('group.message_created', ns, seq, v1Msg);
|
|
4837
|
+
decrypted.push(v1Msg);
|
|
4838
|
+
continue;
|
|
4839
|
+
}
|
|
4840
|
+
}
|
|
4841
|
+
else if (payload !== undefined && payload !== null) {
|
|
3872
4842
|
const v1Msg = {
|
|
3873
4843
|
message_id: String(msg.message_id ?? ''),
|
|
3874
4844
|
from: String(msg.from_aid ?? ''),
|
|
@@ -3883,51 +4853,45 @@ export class AUNClient {
|
|
|
3883
4853
|
decrypted.push(v1Msg);
|
|
3884
4854
|
continue;
|
|
3885
4855
|
}
|
|
4856
|
+
this._clientLog.debug(`group.v2.pull skipping V1 envelope group=${gid} seq=${seq} payload_type=${payloadObj ? String(payloadObj.type ?? '') : '<none>'} (V1 E2EE removed)`);
|
|
4857
|
+
continue;
|
|
3886
4858
|
}
|
|
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);
|
|
4859
|
+
if (version !== 'v2') {
|
|
4860
|
+
this._clientLog.debug(`group.v2.pull skipping non-V2 row group=${gid} seq=${seq} version=${String(msg.version ?? '')}`);
|
|
3900
4861
|
continue;
|
|
3901
4862
|
}
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
this.
|
|
3907
|
-
|
|
4863
|
+
const plaintext = await this._decryptV2Message(msg);
|
|
4864
|
+
if (plaintext === null)
|
|
4865
|
+
continue;
|
|
4866
|
+
plaintext.group_id = gid;
|
|
4867
|
+
await this._publishPulledMessage('group.message_created', ns, seq, plaintext);
|
|
4868
|
+
decrypted.push(plaintext);
|
|
3908
4869
|
}
|
|
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);
|
|
4870
|
+
const cursor = isJsonObject(result.cursor) ? result.cursor : null;
|
|
4871
|
+
const serverAckSeq = Number(cursor?.current_seq ?? 0);
|
|
4872
|
+
if (Number.isFinite(serverAckSeq) && serverAckSeq > 0) {
|
|
4873
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
4874
|
+
if (contig < serverAckSeq) {
|
|
4875
|
+
this._clientLog.info(`group.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> cursor.current_seq=${serverAckSeq}`);
|
|
4876
|
+
this._seqTracker.forceContiguousSeq(ns, serverAckSeq);
|
|
4877
|
+
}
|
|
3923
4878
|
}
|
|
3924
4879
|
const ackSeq = this._seqTracker.getContiguousSeq(ns);
|
|
3925
|
-
|
|
4880
|
+
const contigAdvanced = ackSeq !== pageContigBefore;
|
|
4881
|
+
if (contigAdvanced) {
|
|
4882
|
+
await this._drainOrderedMessages(ns);
|
|
3926
4883
|
this._saveSeqTrackerState();
|
|
3927
|
-
if (ackSeq > 0) {
|
|
3928
|
-
this._safeAsync(this.ackGroupV2(gid, ackSeq).then(() => undefined));
|
|
3929
|
-
}
|
|
3930
4884
|
}
|
|
4885
|
+
if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
|
|
4886
|
+
this._safeAsync(this.ackGroupV2(gid, ackSeq).then(() => undefined));
|
|
4887
|
+
}
|
|
4888
|
+
const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
|
|
4889
|
+
if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
|
|
4890
|
+
break;
|
|
4891
|
+
nextAfterSeq = nextAfter;
|
|
4892
|
+
}
|
|
4893
|
+
if (pageCount >= maxPages) {
|
|
4894
|
+
this._clientLog.warn(`group.v2.pull reached max_pages=${maxPages} group=${gid} after_seq=${nextAfterSeq}`);
|
|
3931
4895
|
}
|
|
3932
4896
|
return decrypted;
|
|
3933
4897
|
}
|
|
@@ -3942,9 +4906,15 @@ export class AUNClient {
|
|
|
3942
4906
|
if (!gid)
|
|
3943
4907
|
throw new ValidationError('group.ack_messages requires group_id');
|
|
3944
4908
|
const ns = `group:${gid}`;
|
|
3945
|
-
|
|
4909
|
+
let seq = upToSeq ?? this._seqTracker.getContiguousSeq(ns);
|
|
3946
4910
|
if (seq <= 0)
|
|
3947
4911
|
return { acked: 0 };
|
|
4912
|
+
// ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
|
|
4913
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
4914
|
+
if (maxSeen > 0 && seq > maxSeen) {
|
|
4915
|
+
this._clientLog.warn(`ackGroupV2 clamp: group=${gid} up_to_seq=${seq} > max_seen=${maxSeen}, clamp`);
|
|
4916
|
+
seq = maxSeen;
|
|
4917
|
+
}
|
|
3948
4918
|
return this.call('group.v2.ack', { group_id: gid, up_to_seq: seq });
|
|
3949
4919
|
}
|
|
3950
4920
|
// ── V2 thought(per-device wrap,服务端透传,不持久化)──────────
|
|
@@ -3984,33 +4954,27 @@ export class AUNClient {
|
|
|
3984
4954
|
}
|
|
3985
4955
|
const targets = [];
|
|
3986
4956
|
for (const dev of peerDevices) {
|
|
3987
|
-
const
|
|
3988
|
-
const
|
|
3989
|
-
|
|
3990
|
-
targets.push({
|
|
4957
|
+
const devId = getV2DeviceId(dev);
|
|
4958
|
+
const target = await this._v2BuildTargetFromDevice({
|
|
4959
|
+
dev,
|
|
3991
4960
|
aid: to,
|
|
3992
|
-
deviceId:
|
|
4961
|
+
deviceId: devId.value,
|
|
3993
4962
|
role: 'peer',
|
|
3994
|
-
|
|
3995
|
-
ikPkDer: ikDer,
|
|
3996
|
-
spkPkDer: spkDer,
|
|
3997
|
-
spkId: String(dev.spk_id ?? ''),
|
|
4963
|
+
defaultKeySource: 'peer_device_prekey',
|
|
3998
4964
|
});
|
|
4965
|
+
if (target)
|
|
4966
|
+
targets.push(target);
|
|
3999
4967
|
}
|
|
4000
4968
|
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({
|
|
4969
|
+
const target = await this._v2BuildTargetFromDevice({
|
|
4970
|
+
dev,
|
|
4006
4971
|
aid: String(dev.aid ?? ''),
|
|
4007
4972
|
deviceId: String(dev.device_id ?? ''),
|
|
4008
4973
|
role: 'audit',
|
|
4009
|
-
|
|
4010
|
-
ikPkDer: ikDer,
|
|
4011
|
-
spkPkDer: spkDer,
|
|
4012
|
-
spkId: String(dev.spk_id ?? ''),
|
|
4974
|
+
defaultKeySource: 'peer_device_prekey',
|
|
4013
4975
|
});
|
|
4976
|
+
if (target)
|
|
4977
|
+
targets.push(target);
|
|
4014
4978
|
}
|
|
4015
4979
|
// self-sync:自己其它设备
|
|
4016
4980
|
if (this._aid && this._aid !== to) {
|
|
@@ -4032,20 +4996,18 @@ export class AUNClient {
|
|
|
4032
4996
|
}
|
|
4033
4997
|
}
|
|
4034
4998
|
for (const dev of selfDevices) {
|
|
4035
|
-
const devId =
|
|
4036
|
-
if (devId === this._deviceId)
|
|
4999
|
+
const devId = getV2DeviceId(dev);
|
|
5000
|
+
if (!devId.present || devId.value === this._deviceId)
|
|
4037
5001
|
continue;
|
|
4038
|
-
const
|
|
4039
|
-
|
|
4040
|
-
targets.push({
|
|
5002
|
+
const target = await this._v2BuildTargetFromDevice({
|
|
5003
|
+
dev,
|
|
4041
5004
|
aid: this._aid,
|
|
4042
|
-
deviceId: devId,
|
|
5005
|
+
deviceId: devId.value,
|
|
4043
5006
|
role: 'self_sync',
|
|
4044
|
-
|
|
4045
|
-
ikPkDer: ikDer,
|
|
4046
|
-
spkPkDer: spkDer,
|
|
4047
|
-
spkId: String(dev.spk_id ?? ''),
|
|
5007
|
+
defaultKeySource: 'peer_device_prekey',
|
|
4048
5008
|
});
|
|
5009
|
+
if (target)
|
|
5010
|
+
targets.push(target);
|
|
4049
5011
|
}
|
|
4050
5012
|
}
|
|
4051
5013
|
catch (exc) {
|
|
@@ -4139,6 +5101,9 @@ export class AUNClient {
|
|
|
4139
5101
|
allDevices = (Array.isArray(bs?.devices) ? bs.devices : []);
|
|
4140
5102
|
epoch = Number(bs?.epoch ?? 0);
|
|
4141
5103
|
auditRecipientsRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
|
|
5104
|
+
await this._v2CheckFork(groupId, String(bs?.state_chain ?? ''));
|
|
5105
|
+
await this._v2VerifyStateSignature(groupId, bs);
|
|
5106
|
+
await this._publishV2GroupSecurityLevel(groupId, bs);
|
|
4142
5107
|
stateCommitment = {
|
|
4143
5108
|
state_version: Number(bs?.state_version ?? 0) || 0,
|
|
4144
5109
|
state_hash: String(bs?.state_hash_signed ?? bs?.state_hash ?? ''),
|
|
@@ -4153,9 +5118,6 @@ export class AUNClient {
|
|
|
4153
5118
|
stateCommitment: stateCommitment,
|
|
4154
5119
|
});
|
|
4155
5120
|
}
|
|
4156
|
-
await this._v2CheckFork(groupId, String(bs?.state_chain ?? ''));
|
|
4157
|
-
await this._v2VerifyStateSignature(groupId, bs);
|
|
4158
|
-
await this._publishV2GroupSecurityLevel(groupId, bs);
|
|
4159
5121
|
// lazy sync 触发:发现 pending members 时异步发起提案
|
|
4160
5122
|
const pendingAdds = Array.isArray(bs?.pending_adds) ? bs.pending_adds : [];
|
|
4161
5123
|
if (pendingAdds.length > 0 && this._v2Session) {
|
|
@@ -4168,36 +5130,30 @@ export class AUNClient {
|
|
|
4168
5130
|
const targets = [];
|
|
4169
5131
|
for (const dev of allDevices) {
|
|
4170
5132
|
const devAid = String(dev.aid ?? '');
|
|
4171
|
-
const devId =
|
|
4172
|
-
if (devAid === this._aid && devId === this._deviceId)
|
|
5133
|
+
const devId = getV2DeviceId(dev);
|
|
5134
|
+
if (devAid === this._aid && devId.present && devId.value === this._deviceId)
|
|
4173
5135
|
continue;
|
|
4174
|
-
const ikDer = _v2B64ToBytes(String(dev.ik_pk ?? ''));
|
|
4175
|
-
const spkDer = dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined;
|
|
4176
5136
|
const role = devAid === this._aid ? 'self_sync' : 'member';
|
|
4177
|
-
|
|
5137
|
+
const target = await this._v2BuildTargetFromDevice({
|
|
5138
|
+
dev,
|
|
4178
5139
|
aid: devAid,
|
|
4179
|
-
deviceId: devId,
|
|
5140
|
+
deviceId: devId.value,
|
|
4180
5141
|
role,
|
|
4181
|
-
|
|
4182
|
-
ikPkDer: ikDer,
|
|
4183
|
-
spkPkDer: spkDer,
|
|
4184
|
-
spkId: String(dev.spk_id ?? ''),
|
|
5142
|
+
defaultKeySource: 'peer_device_prekey',
|
|
4185
5143
|
});
|
|
5144
|
+
if (target)
|
|
5145
|
+
targets.push(target);
|
|
4186
5146
|
}
|
|
4187
5147
|
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({
|
|
5148
|
+
const target = await this._v2BuildTargetFromDevice({
|
|
5149
|
+
dev,
|
|
4193
5150
|
aid: String(dev.aid ?? ''),
|
|
4194
5151
|
deviceId: String(dev.device_id ?? ''),
|
|
4195
5152
|
role: 'audit',
|
|
4196
|
-
|
|
4197
|
-
ikPkDer: ikDer,
|
|
4198
|
-
spkPkDer: spkDer,
|
|
4199
|
-
spkId: String(dev.spk_id ?? ''),
|
|
5153
|
+
defaultKeySource: 'peer_device_prekey',
|
|
4200
5154
|
});
|
|
5155
|
+
if (target)
|
|
5156
|
+
targets.push(target);
|
|
4201
5157
|
}
|
|
4202
5158
|
if (targets.length === 0) {
|
|
4203
5159
|
throw new E2EEError(`V2 group: no target devices for ${groupId}`);
|
|
@@ -4294,26 +5250,33 @@ export class AUNClient {
|
|
|
4294
5250
|
}
|
|
4295
5251
|
}
|
|
4296
5252
|
}
|
|
4297
|
-
// 根据 aad.group_id 判断使用 group SPK 还是 P2P SPK
|
|
4298
5253
|
const aad = opts.envelope.aad ?? {};
|
|
4299
5254
|
const groupIdForKeys = String(aad.group_id ?? opts.envelope.group_id ?? '').trim();
|
|
4300
5255
|
let ikPriv;
|
|
4301
5256
|
let spkPriv;
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
5257
|
+
// group_id 只表示群上下文;group lookup 内部按 group SPK -> P2P device SPK -> IK fallback。
|
|
5258
|
+
try {
|
|
5259
|
+
if (groupIdForKeys) {
|
|
5260
|
+
const keys = await session.getGroupDecryptKeys(groupIdForKeys, spkId);
|
|
5261
|
+
ikPriv = keys.ikPriv;
|
|
5262
|
+
spkPriv = keys.spkPriv;
|
|
5263
|
+
}
|
|
5264
|
+
else {
|
|
5265
|
+
const keys = await session.getDecryptKeys(spkId);
|
|
5266
|
+
ikPriv = keys.ikPriv;
|
|
5267
|
+
spkPriv = keys.spkPriv;
|
|
5268
|
+
}
|
|
4306
5269
|
}
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
spkPriv = keys.spkPriv;
|
|
5270
|
+
catch (exc) {
|
|
5271
|
+
this._clientLog.warn(`V2 thought decrypt: SPK lookup failed from=${opts.fromAid}, group=${groupIdForKeys || '<p2p>'}, spk_id=${spkId || '<empty>'}: ${exc}`);
|
|
5272
|
+
return null;
|
|
4311
5273
|
}
|
|
4312
5274
|
const fromAid = String(opts.fromAid || aad.from || '').trim();
|
|
4313
5275
|
const senderDeviceId = String(aad.from_device ?? '');
|
|
4314
5276
|
const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId);
|
|
4315
5277
|
if (!senderPubDer) {
|
|
4316
5278
|
this._clientLog.warn(`V2 thought decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
|
|
5279
|
+
this._scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupIdForKeys);
|
|
4317
5280
|
return null;
|
|
4318
5281
|
}
|
|
4319
5282
|
try {
|
|
@@ -4367,11 +5330,8 @@ export class AUNClient {
|
|
|
4367
5330
|
const signPayloadBytes = new TextEncoder().encode(signPayload);
|
|
4368
5331
|
const sigBytes = base64ToUint8(stateSignature);
|
|
4369
5332
|
// 验签缓存检查
|
|
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);
|
|
5333
|
+
const cacheData = _v2LengthPrefixedBytes(new TextEncoder().encode(actorAid), signPayloadBytes, sigBytes);
|
|
5334
|
+
const cacheHashBuf = await crypto.subtle.digest('SHA-256', cacheData.slice().buffer);
|
|
4375
5335
|
const cacheHashArr = new Uint8Array(cacheHashBuf);
|
|
4376
5336
|
let cacheKey = '';
|
|
4377
5337
|
for (let i = 0; i < cacheHashArr.length; i++)
|
|
@@ -4599,8 +5559,9 @@ export class AUNClient {
|
|
|
4599
5559
|
const candidates = [];
|
|
4600
5560
|
for (const dev of devices) {
|
|
4601
5561
|
const aid = String(dev.aid ?? '').trim();
|
|
5562
|
+
const hasDeviceId = 'device_id' in dev;
|
|
4602
5563
|
const deviceId = String(dev.device_id ?? '').trim();
|
|
4603
|
-
if (aid &&
|
|
5564
|
+
if (aid && hasDeviceId && onlineAdminAids.has(aid)) {
|
|
4604
5565
|
candidates.push(`${aid}\x1f${deviceId}`);
|
|
4605
5566
|
}
|
|
4606
5567
|
}
|
|
@@ -4616,7 +5577,7 @@ export class AUNClient {
|
|
|
4616
5577
|
this._clientLog.debug(`V2 auto propose leader elected: group=${groupId} leader=${leader}`);
|
|
4617
5578
|
return true;
|
|
4618
5579
|
}
|
|
4619
|
-
const delayMs = await this._v2LeaderDelayMs(
|
|
5580
|
+
const delayMs = await this._v2LeaderDelayMs(_v2LengthPrefixedTextKey(groupId, myKey));
|
|
4620
5581
|
this._clientLog.debug(`V2 auto propose non-leader delay: group=${groupId} leader=${leader} self=${myKey} delay_ms=${delayMs}`);
|
|
4621
5582
|
await this._sleep(delayMs);
|
|
4622
5583
|
return true;
|
|
@@ -4929,30 +5890,42 @@ export class AUNClient {
|
|
|
4929
5890
|
const pushFrom = isJsonObject(data) ? String(data.from_aid ?? '') : '';
|
|
4930
5891
|
const pushMsgId = isJsonObject(data) ? String(data.message_id ?? '') : '';
|
|
4931
5892
|
const envelopeJson = isJsonObject(data) ? data.envelope_json : undefined;
|
|
5893
|
+
const hasPayload = !!envelopeJson;
|
|
4932
5894
|
const ns = this._aid ? `p2p:${this._aid}` : '';
|
|
4933
5895
|
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=${
|
|
5896
|
+
this._clientLog.debug(`_onV2PushNotification: push_seq=${pushSeq || 'null'} push_from=${pushFrom} push_msg_id=${pushMsgId} has_payload=${hasPayload} contiguous_seq=${contigBefore}`);
|
|
5897
|
+
// ── Push 修上界:只更新 maxSeenSeq,不动 contiguousSeq ──
|
|
5898
|
+
if (pushSeq > 0 && ns) {
|
|
5899
|
+
this._seqTracker.updateMaxSeen(ns, pushSeq);
|
|
5900
|
+
if (contigBefore === pushSeq) {
|
|
5901
|
+
this._clientLog.debug(`_onV2PushNotification: push seq=${pushSeq} already covered by contiguous_seq=${contigBefore}, ignore duplicate push`);
|
|
5902
|
+
return;
|
|
5903
|
+
}
|
|
5904
|
+
contigBefore = this._repairPushContiguousBound(ns, pushSeq, hasPayload, '_raw.peer.v2.message_received');
|
|
5905
|
+
}
|
|
4935
5906
|
// ── 带 payload 的 push:尝试就地解密 ──
|
|
4936
|
-
if (
|
|
5907
|
+
if (hasPayload && pushSeq > 0 && ns) {
|
|
4937
5908
|
try {
|
|
4938
5909
|
const decrypted = await this._decryptV2Message(data);
|
|
4939
5910
|
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);
|
|
5911
|
+
// 解密成功:把 pushSeq 加入 receivedSeqs,让 _tryAdvance 自然推进
|
|
5912
|
+
const needPull = this._seqTracker.onMessageSeq(ns, pushSeq);
|
|
5913
|
+
const published = await this._publishOrderedMessage('message.received', ns, pushSeq, decrypted);
|
|
4946
5914
|
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
4947
5915
|
if (newContig !== contigBefore) {
|
|
4948
5916
|
this._saveSeqTrackerState();
|
|
4949
5917
|
}
|
|
4950
5918
|
if (newContig > 0 && newContig !== contigBefore) {
|
|
4951
|
-
this.
|
|
5919
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
5920
|
+
const ackSeq = maxSeen > 0 ? Math.min(newContig, maxSeen) : newContig;
|
|
5921
|
+
this.call('message.v2.ack', { up_to_seq: ackSeq })
|
|
4952
5922
|
.catch(e => this._clientLog.debug(`V2 P2P push-ack failed: ${e}`));
|
|
4953
5923
|
}
|
|
4954
5924
|
this._clientLog.debug(`_onV2PushNotification: push 带 payload 解密成功, contiguous_seq=${contigBefore}->${newContig} push_seq=${pushSeq}`);
|
|
4955
|
-
|
|
5925
|
+
if (!needPull && (published || newContig >= pushSeq || pushSeq <= contigBefore)) {
|
|
5926
|
+
return;
|
|
5927
|
+
}
|
|
5928
|
+
this._clientLog.debug(`_onV2PushNotification: payload push seq=${pushSeq} 因空洞挂起,继续 pull 补齐 after_seq=${newContig}`);
|
|
4956
5929
|
}
|
|
4957
5930
|
}
|
|
4958
5931
|
catch (exc) {
|
|
@@ -4966,14 +5939,6 @@ export class AUNClient {
|
|
|
4966
5939
|
// 正确做法:保持 contiguousSeq 不变,用它作为 pull 的 after_seq;
|
|
4967
5940
|
// pull 成功 + 解密成功后再由 pull 路径推进 contiguousSeq。
|
|
4968
5941
|
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
5942
|
// 纯通知:不更新 contiguousSeq,由 pull 结果驱动推进
|
|
4978
5943
|
this._clientLog.debug(`_onV2PushNotification: 纯通知 push_seq=${pushSeq} > contiguous_seq=${contigBefore}, 触发 pull(after_seq=${contigBefore})`);
|
|
4979
5944
|
}
|