@agentunion/fastaun-browser 0.4.7 → 0.4.9
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 +49 -0
- package/_packed_docs/CHANGELOG.md +49 -0
- package/_packed_docs/INDEX.md +61 -31
- package/_packed_docs/KITE_DOCS_GUIDE.md +18 -13
- package/_packed_docs/design/AUNClient/346/213/206/345/210/206/351/207/215/346/236/204/346/211/247/350/241/214/346/226/271/346/241/210.md +859 -0
- package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +5 -4
- package/_packed_docs/sdk/INDEX.md +15 -9
- package/_packed_docs/sdk/README.md +3 -2
- package/dist/agent-md.d.ts +1 -1
- package/dist/agent-md.d.ts.map +1 -1
- package/dist/agent-md.js +12 -5
- package/dist/agent-md.js.map +1 -1
- package/dist/aid-store.d.ts +0 -1
- package/dist/aid-store.d.ts.map +1 -1
- package/dist/aid-store.js +29 -14
- package/dist/aid-store.js.map +1 -1
- package/dist/aid.d.ts +1 -0
- package/dist/aid.d.ts.map +1 -1
- package/dist/aid.js +10 -4
- package/dist/aid.js.map +1 -1
- package/dist/bundle.js +10005 -8912
- package/dist/cert-utils.d.ts +5 -1
- package/dist/cert-utils.d.ts.map +1 -1
- package/dist/cert-utils.js +52 -8
- package/dist/cert-utils.js.map +1 -1
- package/dist/client/delivery.d.ts +44 -0
- package/dist/client/delivery.d.ts.map +1 -0
- package/dist/client/delivery.js +1033 -0
- package/dist/client/delivery.js.map +1 -0
- package/dist/client/group-state.d.ts +32 -0
- package/dist/client/group-state.d.ts.map +1 -0
- package/dist/client/group-state.js +901 -0
- package/dist/client/group-state.js.map +1 -0
- package/dist/client/identity.d.ts +8 -0
- package/dist/client/identity.d.ts.map +1 -0
- package/dist/client/identity.js +28 -0
- package/dist/client/identity.js.map +1 -0
- package/dist/client/lifecycle.d.ts +12 -0
- package/dist/client/lifecycle.d.ts.map +1 -0
- package/dist/client/lifecycle.js +282 -0
- package/dist/client/lifecycle.js.map +1 -0
- package/dist/client/peers.d.ts +11 -0
- package/dist/client/peers.d.ts.map +1 -0
- package/dist/client/peers.js +42 -0
- package/dist/client/peers.js.map +1 -0
- package/dist/client/rpc-pipeline.d.ts +39 -0
- package/dist/client/rpc-pipeline.d.ts.map +1 -0
- package/dist/client/rpc-pipeline.js +577 -0
- package/dist/client/rpc-pipeline.js.map +1 -0
- package/dist/client/runtime.d.ts +90 -0
- package/dist/client/runtime.d.ts.map +1 -0
- package/dist/client/runtime.js +241 -0
- package/dist/client/runtime.js.map +1 -0
- package/dist/client/v2-e2ee.d.ts +104 -0
- package/dist/client/v2-e2ee.d.ts.map +1 -0
- package/dist/client/v2-e2ee.js +1579 -0
- package/dist/client/v2-e2ee.js.map +1 -0
- package/dist/client.d.ts +27 -81
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +284 -4015
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -3
- package/dist/config.js.map +1 -1
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +69 -10
- package/dist/discovery.js.map +1 -1
- package/dist/v2/crypto/dh-path.js +2 -2
- package/dist/v2/crypto/index.js +7 -7
- package/dist/v2/e2ee/decrypt.js +8 -8
- package/dist/v2/e2ee/encrypt-group.js +9 -9
- package/dist/v2/e2ee/encrypt-p2p.js +9 -9
- package/dist/v2/e2ee/index.js +5 -5
- package/dist/v2/e2ee/metadata-auth.js +1 -1
- package/dist/v2/session/index.js +2 -2
- package/dist/v2/session/session.js +1 -1
- package/dist/v2/state/commitment.js +1 -1
- package/dist/v2/state/index.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
import { base64ToUint8, ecdsaSignDer, ecdsaVerifyDer, importCertPublicKeyEcdsa, importPrivateKeyEcdsa, uint8ToBase64, } from '../crypto.js';
|
|
2
|
+
import { E2EEError } from '../errors.js';
|
|
3
|
+
import { normalizeGroupId } from '../group-id.js';
|
|
4
|
+
import { isJsonObject, } from '../types.js';
|
|
5
|
+
import { computeStateCommitment } from '../v2/state/index.js';
|
|
6
|
+
const MEMBERSHIP_MUTATION_METHODS = new Set([
|
|
7
|
+
'group.create',
|
|
8
|
+
'group.add_member',
|
|
9
|
+
'group.kick',
|
|
10
|
+
'group.remove_member',
|
|
11
|
+
'group.leave',
|
|
12
|
+
'group.review_join_request',
|
|
13
|
+
'group.batch_review_join_request',
|
|
14
|
+
'group.use_invite_code',
|
|
15
|
+
'group.request_join',
|
|
16
|
+
]);
|
|
17
|
+
const SPK_REGISTRATION_MUTATION_METHODS = new Set([
|
|
18
|
+
'group.create',
|
|
19
|
+
'group.use_invite_code',
|
|
20
|
+
]);
|
|
21
|
+
const MEMBERSHIP_ACTIONS = new Set([
|
|
22
|
+
'member_added',
|
|
23
|
+
'member_left',
|
|
24
|
+
'member_removed',
|
|
25
|
+
'role_changed',
|
|
26
|
+
'owner_transferred',
|
|
27
|
+
'joined',
|
|
28
|
+
'join_approved',
|
|
29
|
+
'invite_code_used',
|
|
30
|
+
]);
|
|
31
|
+
const V2_SIG_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
32
|
+
const V2_SIG_CACHE_MAX = 16_384;
|
|
33
|
+
function formatCaughtError(error) {
|
|
34
|
+
return error instanceof Error ? error : String(error);
|
|
35
|
+
}
|
|
36
|
+
function stableStringify(obj) {
|
|
37
|
+
if (obj === null || obj === undefined)
|
|
38
|
+
return 'null';
|
|
39
|
+
if (typeof obj === 'boolean' || typeof obj === 'number')
|
|
40
|
+
return JSON.stringify(obj);
|
|
41
|
+
if (typeof obj === 'string')
|
|
42
|
+
return JSON.stringify(obj);
|
|
43
|
+
if (Array.isArray(obj)) {
|
|
44
|
+
return '[' + obj.map(v => stableStringify(v)).join(',') + ']';
|
|
45
|
+
}
|
|
46
|
+
if (isJsonObject(obj)) {
|
|
47
|
+
const entries = Object.keys(obj).sort()
|
|
48
|
+
.filter(k => obj[k] !== undefined)
|
|
49
|
+
.map(k => stableStringify(k) + ':' + stableStringify(obj[k]));
|
|
50
|
+
return '{' + entries.join(',') + '}';
|
|
51
|
+
}
|
|
52
|
+
return JSON.stringify(obj);
|
|
53
|
+
}
|
|
54
|
+
function lengthPrefixedBytesKey(...parts) {
|
|
55
|
+
const encoder = new TextEncoder();
|
|
56
|
+
const frames = [];
|
|
57
|
+
let total = 0;
|
|
58
|
+
for (const part of parts) {
|
|
59
|
+
const prefix = encoder.encode(`${part.byteLength}:`);
|
|
60
|
+
const suffix = encoder.encode(';');
|
|
61
|
+
frames.push(prefix, part, suffix);
|
|
62
|
+
total += prefix.byteLength + part.byteLength + suffix.byteLength;
|
|
63
|
+
}
|
|
64
|
+
const out = new Uint8Array(total);
|
|
65
|
+
let offset = 0;
|
|
66
|
+
for (const frame of frames) {
|
|
67
|
+
out.set(frame, offset);
|
|
68
|
+
offset += frame.byteLength;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
function bytesToHex(bytes) {
|
|
73
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
74
|
+
}
|
|
75
|
+
function sortObjectKeys(obj) {
|
|
76
|
+
const sorted = {};
|
|
77
|
+
for (const key of Object.keys(obj).sort()) {
|
|
78
|
+
sorted[key] = obj[key];
|
|
79
|
+
}
|
|
80
|
+
return sorted;
|
|
81
|
+
}
|
|
82
|
+
function hexToBytes(hex) {
|
|
83
|
+
const clean = hex.trim();
|
|
84
|
+
if (!clean)
|
|
85
|
+
return new Uint8Array();
|
|
86
|
+
const out = new Uint8Array(clean.length / 2);
|
|
87
|
+
for (let i = 0; i < out.length; i++) {
|
|
88
|
+
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
function uint64BE(value) {
|
|
93
|
+
const out = new Uint8Array(8);
|
|
94
|
+
const view = new DataView(out.buffer);
|
|
95
|
+
const normalized = Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
96
|
+
view.setBigUint64(0, BigInt(normalized), false);
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
function lengthPrefixedTextKey(...parts) {
|
|
100
|
+
const encoder = new TextEncoder();
|
|
101
|
+
return parts.map((part) => `${encoder.encode(part).byteLength}:${part};`).join('');
|
|
102
|
+
}
|
|
103
|
+
function v2WrapCapabilities() {
|
|
104
|
+
return {
|
|
105
|
+
version: 'v2.1',
|
|
106
|
+
protocols: ['1DH', '3DH'],
|
|
107
|
+
scopes: ['aid', 'device'],
|
|
108
|
+
per_aid_wrap: true,
|
|
109
|
+
per_device_wrap: true,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
async function computeStateHash(params) {
|
|
113
|
+
const sortedMembers = [...params.members].sort((a, b) => a.aid.localeCompare(b.aid));
|
|
114
|
+
const membershipBlock = sortedMembers.map((m) => `${m.aid}:${m.role}`).join('|');
|
|
115
|
+
const policyBlock = Object.keys(params.policy).length > 0
|
|
116
|
+
? JSON.stringify(sortObjectKeys(params.policy))
|
|
117
|
+
: '';
|
|
118
|
+
const prevBytes = params.prevStateHash ? hexToBytes(params.prevStateHash) : new Uint8Array(32);
|
|
119
|
+
const sep = new Uint8Array([0x00]);
|
|
120
|
+
const encoder = new TextEncoder();
|
|
121
|
+
const chunks = [
|
|
122
|
+
encoder.encode(params.groupId), sep,
|
|
123
|
+
uint64BE(params.stateVersion), sep,
|
|
124
|
+
uint64BE(params.keyEpoch), sep,
|
|
125
|
+
encoder.encode(membershipBlock), sep,
|
|
126
|
+
encoder.encode(policyBlock), sep,
|
|
127
|
+
prevBytes,
|
|
128
|
+
];
|
|
129
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
130
|
+
const data = new Uint8Array(total);
|
|
131
|
+
let offset = 0;
|
|
132
|
+
for (const chunk of chunks) {
|
|
133
|
+
data.set(chunk, offset);
|
|
134
|
+
offset += chunk.length;
|
|
135
|
+
}
|
|
136
|
+
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
137
|
+
return bytesToHex(new Uint8Array(digest));
|
|
138
|
+
}
|
|
139
|
+
function normalizedGroupId(raw) {
|
|
140
|
+
const groupId = String(raw ?? '').trim();
|
|
141
|
+
return normalizeGroupId(groupId) || groupId;
|
|
142
|
+
}
|
|
143
|
+
export class GroupStateCoordinator {
|
|
144
|
+
runtime;
|
|
145
|
+
constructor(runtime) {
|
|
146
|
+
this.runtime = runtime;
|
|
147
|
+
}
|
|
148
|
+
get client() {
|
|
149
|
+
return this.runtime.client;
|
|
150
|
+
}
|
|
151
|
+
async postprocessResult(method, params, result) {
|
|
152
|
+
const client = this.client;
|
|
153
|
+
if (!MEMBERSHIP_MUTATION_METHODS.has(method) || !isJsonObject(result) || 'error' in result || !client._v2Session) {
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
const groupId = this.extractGroupIdFromMutationResult(result, params);
|
|
157
|
+
if (!groupId) {
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
await client._v2AutoProposeState(groupId);
|
|
162
|
+
}
|
|
163
|
+
catch (exc) {
|
|
164
|
+
client._clientLog?.debug?.(`V2 post-membership propose failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
|
|
165
|
+
}
|
|
166
|
+
if (SPK_REGISTRATION_MUTATION_METHODS.has(method)) {
|
|
167
|
+
client._v2E2EE?.scheduleGroupSpkRegistration?.(groupId, { reason: method });
|
|
168
|
+
}
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
handleGroupChangedV2Membership(data) {
|
|
172
|
+
const client = this.client;
|
|
173
|
+
const groupId = normalizedGroupId(data.group_id);
|
|
174
|
+
const action = String(data.action ?? '').trim();
|
|
175
|
+
if (!groupId) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
client._v2E2EE?.deleteBootstrapCacheEntry?.(`group:${groupId}`);
|
|
179
|
+
const membershipAction = MEMBERSHIP_ACTIONS.has(action);
|
|
180
|
+
if (client._v2Session && (action === 'upsert' || membershipAction)) {
|
|
181
|
+
client._safeAsync(client._v2AutoProposeState(groupId, { leaderDelay: true }));
|
|
182
|
+
}
|
|
183
|
+
client._v2E2EE?.handleGroupChangedSpk?.(data, groupId, action);
|
|
184
|
+
}
|
|
185
|
+
async onV2StateProposed(data) {
|
|
186
|
+
const client = this.client;
|
|
187
|
+
if (!isJsonObject(data) || !client._v2Session)
|
|
188
|
+
return;
|
|
189
|
+
const groupId = normalizedGroupId(data.group_id);
|
|
190
|
+
if (!groupId)
|
|
191
|
+
return;
|
|
192
|
+
await client._dispatcher.publish('group.v2.state_proposed', data);
|
|
193
|
+
try {
|
|
194
|
+
await client._v2ConfirmPendingProposal(groupId);
|
|
195
|
+
}
|
|
196
|
+
catch (exc) {
|
|
197
|
+
client._clientLog.debug(`V2 state_proposed handling failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async onV2StateRetryNeeded(data) {
|
|
201
|
+
const client = this.client;
|
|
202
|
+
if (!isJsonObject(data) || !client._v2Session)
|
|
203
|
+
return;
|
|
204
|
+
const groupId = normalizedGroupId(data.group_id);
|
|
205
|
+
if (!groupId)
|
|
206
|
+
return;
|
|
207
|
+
await client._dispatcher.publish('group.v2.state_retry_needed', data);
|
|
208
|
+
try {
|
|
209
|
+
await client._v2AutoProposeState(groupId, { leaderDelay: true });
|
|
210
|
+
}
|
|
211
|
+
catch (exc) {
|
|
212
|
+
client._clientLog.debug(`V2 state_retry_needed handling failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async onV2StateConfirmed(data) {
|
|
216
|
+
const client = this.client;
|
|
217
|
+
if (!isJsonObject(data))
|
|
218
|
+
return;
|
|
219
|
+
const groupId = normalizedGroupId(data.group_id);
|
|
220
|
+
if (groupId) {
|
|
221
|
+
client._v2E2EE?.deleteBootstrapCacheEntry?.(`group:${groupId}`);
|
|
222
|
+
client._v2AutoProposeLastSnapshot?.delete?.(groupId);
|
|
223
|
+
}
|
|
224
|
+
await client._dispatcher.publish('group.v2.state_confirmed', data);
|
|
225
|
+
}
|
|
226
|
+
async publishV2GroupSecurityLevel(groupId, bootstrap) {
|
|
227
|
+
const client = this.client;
|
|
228
|
+
const gid = normalizedGroupId(groupId);
|
|
229
|
+
if (!gid)
|
|
230
|
+
return;
|
|
231
|
+
const level = String(bootstrap.e2ee_security_level ?? '').trim() || 'end_to_end';
|
|
232
|
+
const securityLevels = this.runtime.groupState.securityLevels;
|
|
233
|
+
const previous = securityLevels.get(gid);
|
|
234
|
+
if (previous === level)
|
|
235
|
+
return;
|
|
236
|
+
securityLevels.set(gid, level);
|
|
237
|
+
await client._dispatcher.publish('group.v2.security_level', {
|
|
238
|
+
group_id: gid,
|
|
239
|
+
level,
|
|
240
|
+
warning: String(bootstrap.e2ee_security_warning ?? ''),
|
|
241
|
+
previous_level: previous ?? null,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
async verifyStateSignature(groupId, bootstrap) {
|
|
245
|
+
const client = this.client;
|
|
246
|
+
const gid = normalizedGroupId(groupId);
|
|
247
|
+
if (!gid || !bootstrap)
|
|
248
|
+
return;
|
|
249
|
+
const stateSignature = String(bootstrap.state_signature ?? '');
|
|
250
|
+
const actorAid = String(bootstrap.state_actor_aid ?? '');
|
|
251
|
+
const stateHashSigned = String(bootstrap.state_hash_signed ?? '');
|
|
252
|
+
const membershipSnapshot = String(bootstrap.state_membership_snapshot ?? '');
|
|
253
|
+
const stateVersion = Number(bootstrap.state_version ?? 0) || 0;
|
|
254
|
+
if (stateVersion === 0 || !stateSignature || !actorAid)
|
|
255
|
+
return;
|
|
256
|
+
try {
|
|
257
|
+
const signPayload = stableStringify({
|
|
258
|
+
group_id: gid,
|
|
259
|
+
membership_snapshot: membershipSnapshot,
|
|
260
|
+
state_hash: stateHashSigned,
|
|
261
|
+
state_version: stateVersion,
|
|
262
|
+
});
|
|
263
|
+
const signPayloadBytes = new TextEncoder().encode(signPayload);
|
|
264
|
+
const sigBytes = base64ToUint8(stateSignature);
|
|
265
|
+
const framedPayload = lengthPrefixedBytesKey(new TextEncoder().encode(actorAid), signPayloadBytes);
|
|
266
|
+
const cacheData = new Uint8Array(framedPayload.byteLength + sigBytes.byteLength);
|
|
267
|
+
cacheData.set(framedPayload, 0);
|
|
268
|
+
cacheData.set(sigBytes, framedPayload.byteLength);
|
|
269
|
+
const cacheInput = cacheData.buffer.slice(cacheData.byteOffset, cacheData.byteOffset + cacheData.byteLength);
|
|
270
|
+
const cacheHash = new Uint8Array(await crypto.subtle.digest('SHA-256', cacheInput));
|
|
271
|
+
const cacheKey = bytesToHex(cacheHash);
|
|
272
|
+
const sigCache = this.runtime.groupState.sigCache;
|
|
273
|
+
const now = Date.now();
|
|
274
|
+
const cachedExp = sigCache.get(cacheKey);
|
|
275
|
+
if (cachedExp !== undefined && cachedExp > now) {
|
|
276
|
+
client._clientLog.debug(`V2 state signature cache hit: group=${gid} sv=${stateVersion}`);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
const certPem = await client._fetchPeerCert(actorAid);
|
|
280
|
+
if (!certPem) {
|
|
281
|
+
client._clientLog.warn(`V2 state verify: no cert for actor=${actorAid}, group=${gid}`);
|
|
282
|
+
throw new E2EEError(`V2 state verify: cannot fetch actor cert for ${actorAid}`);
|
|
283
|
+
}
|
|
284
|
+
const pubKey = await importCertPublicKeyEcdsa(certPem);
|
|
285
|
+
const ok = await ecdsaVerifyDer(pubKey, sigBytes, signPayloadBytes);
|
|
286
|
+
if (!ok) {
|
|
287
|
+
client._clientLog.warn(`V2 state signature verification FAILED: group=${gid} sv=${stateVersion} actor=${actorAid}`);
|
|
288
|
+
throw new E2EEError('V2 state signature verification failed');
|
|
289
|
+
}
|
|
290
|
+
sigCache.set(cacheKey, now + V2_SIG_CACHE_TTL_MS);
|
|
291
|
+
this.pruneSigCache(now);
|
|
292
|
+
client._clientLog.debug(`V2 state signature verified: group=${gid} sv=${stateVersion} actor=${actorAid}`);
|
|
293
|
+
}
|
|
294
|
+
await this.checkMembershipTamper(gid, bootstrap, membershipSnapshot);
|
|
295
|
+
}
|
|
296
|
+
catch (exc) {
|
|
297
|
+
if (exc instanceof E2EEError)
|
|
298
|
+
throw exc;
|
|
299
|
+
client._clientLog.warn(`V2 state signature verification failed: group=${gid} err=${formatCaughtError(exc)}`);
|
|
300
|
+
throw new E2EEError(`V2 state signature verification failed: ${String(formatCaughtError(exc))}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async checkFork(groupId, serverChain) {
|
|
304
|
+
const client = this.client;
|
|
305
|
+
const gid = normalizedGroupId(groupId);
|
|
306
|
+
if (!gid || !serverChain)
|
|
307
|
+
return;
|
|
308
|
+
try {
|
|
309
|
+
const stateChains = this.runtime.groupState.chains;
|
|
310
|
+
const local = stateChains.get(gid);
|
|
311
|
+
if (local === undefined) {
|
|
312
|
+
stateChains.set(gid, [0, serverChain]);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const [localSv, localChain] = local;
|
|
316
|
+
if (localChain === serverChain)
|
|
317
|
+
return;
|
|
318
|
+
try {
|
|
319
|
+
const stateResp = await client.call('group.get_state', { group_id: gid });
|
|
320
|
+
if (stateResp) {
|
|
321
|
+
const serverSv = Number(stateResp.state_version ?? 0);
|
|
322
|
+
if (serverSv > localSv) {
|
|
323
|
+
stateChains.set(gid, [serverSv, serverChain]);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (serverSv < localSv) {
|
|
327
|
+
client._clientLog.warn(`V2 state chain rollback detected: group=${gid} server_sv=${serverSv} local_sv=${localSv}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
// get_state 失败时继续发布 fork 告警。
|
|
333
|
+
}
|
|
334
|
+
client._clientLog.warn(`V2 state chain fork detected: group=${gid} local_chain=${localChain.slice(0, 16)}... server_chain=${serverChain.slice(0, 16)}...`);
|
|
335
|
+
await client._dispatcher.publish('group.v2.fork_detected', {
|
|
336
|
+
group_id: gid,
|
|
337
|
+
local_chain: localChain,
|
|
338
|
+
server_chain: serverChain,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
catch (exc) {
|
|
342
|
+
client._clientLog.debug(`V2 fork check failed (non-fatal): ${formatCaughtError(exc)}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
maybeTriggerAutoPropose(groupId) {
|
|
346
|
+
const client = this.client;
|
|
347
|
+
const gid = normalizedGroupId(groupId);
|
|
348
|
+
if (!gid)
|
|
349
|
+
return;
|
|
350
|
+
const lazyProposeTriggered = this.runtime.groupState.lazyProposeTriggered;
|
|
351
|
+
const now = Date.now();
|
|
352
|
+
const last = lazyProposeTriggered.get(gid) ?? 0;
|
|
353
|
+
if (now - last < 10000)
|
|
354
|
+
return;
|
|
355
|
+
lazyProposeTriggered.set(gid, now);
|
|
356
|
+
client._safeAsync(client._v2AutoProposeState(gid, { leaderDelay: true }));
|
|
357
|
+
}
|
|
358
|
+
async onGroupStateCommitted(data) {
|
|
359
|
+
const client = this.client;
|
|
360
|
+
const tStart = Date.now();
|
|
361
|
+
const groupIdInit = String(data?.group_id ?? '');
|
|
362
|
+
client._clientLog.debug(`_onGroupStateCommitted enter: group_id=${groupIdInit} state_version=${String(data?.state_version ?? '-')}`);
|
|
363
|
+
try {
|
|
364
|
+
if (!isJsonObject(data)) {
|
|
365
|
+
client._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms reason=non_object`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const groupId = String(data.group_id ?? '').trim();
|
|
369
|
+
if (!groupId) {
|
|
370
|
+
client._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms reason=no_group_id`);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const cs = data.client_signature;
|
|
374
|
+
if (cs && isJsonObject(cs)) {
|
|
375
|
+
if (client._shouldSkipEventSignature(data)) {
|
|
376
|
+
delete data.client_signature;
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
const verified = await client._verifyEventSignature(data, cs);
|
|
380
|
+
if (verified === false) {
|
|
381
|
+
client._clientLog.warn(`state_committed committer signature verify failed group=%s${String(groupId)}`);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
data._verified = verified;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const stateVersion = Number(data.state_version ?? 0);
|
|
388
|
+
const stateHash = String(data.state_hash ?? '').trim();
|
|
389
|
+
const prevStateHash = String(data.prev_state_hash ?? '').trim();
|
|
390
|
+
const keyEpoch = Number(data.key_epoch ?? 0);
|
|
391
|
+
const membershipSnapshot = String(data.membership_snapshot ?? '').trim();
|
|
392
|
+
const policySnapshot = String(data.policy_snapshot ?? '').trim();
|
|
393
|
+
const loadFn = client._tokenStore.loadGroupState;
|
|
394
|
+
const localState = loadFn
|
|
395
|
+
? await loadFn.call(client._tokenStore, groupId)
|
|
396
|
+
: null;
|
|
397
|
+
if (localState && localState.state_hash && localState.state_hash !== prevStateHash) {
|
|
398
|
+
client._clientLog.warn('[aun_core] state_hash 链不连续 group=%s local_sv=%d event_sv=%d', groupId, localState.state_version, stateVersion);
|
|
399
|
+
try {
|
|
400
|
+
const serverState = await client._transport.call('group.get_state', { group_id: groupId });
|
|
401
|
+
if (serverState && typeof serverState.state_version !== 'undefined') {
|
|
402
|
+
const serverObj = serverState;
|
|
403
|
+
const sv = Number(serverObj.state_version);
|
|
404
|
+
const sHash = String(serverObj.state_hash ?? '');
|
|
405
|
+
const sEpoch = Number(serverObj.key_epoch ?? 0);
|
|
406
|
+
const sMembersJson = String(serverObj.membership_snapshot ?? '');
|
|
407
|
+
const sPolicyJson = String(serverObj.policy_snapshot ?? '');
|
|
408
|
+
const sPrev = String(serverObj.prev_state_hash ?? '');
|
|
409
|
+
if (sMembersJson && sHash) {
|
|
410
|
+
const sMembers = sMembersJson ? JSON.parse(sMembersJson) : [];
|
|
411
|
+
const sPolicy = sPolicyJson ? JSON.parse(sPolicyJson) : {};
|
|
412
|
+
const computed = await computeStateHash({
|
|
413
|
+
groupId, stateVersion: sv, keyEpoch: sEpoch,
|
|
414
|
+
members: sMembers, policy: sPolicy, prevStateHash: sPrev,
|
|
415
|
+
});
|
|
416
|
+
if (computed !== sHash) {
|
|
417
|
+
client._clientLog.warn('[aun_core] 回源 state_hash 验证失败 group=%s sv=%d expected=%s got=%s', groupId, sv, sHash, computed);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const saveFn = client._tokenStore.saveGroupState;
|
|
422
|
+
if (saveFn) {
|
|
423
|
+
await saveFn.call(client._tokenStore, groupId, {
|
|
424
|
+
group_id: groupId,
|
|
425
|
+
state_version: sv,
|
|
426
|
+
state_hash: sHash,
|
|
427
|
+
key_epoch: sEpoch,
|
|
428
|
+
membership_json: sMembersJson || membershipSnapshot,
|
|
429
|
+
policy_json: sPolicyJson || policySnapshot,
|
|
430
|
+
updated_at: Date.now(),
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
catch (exc) {
|
|
436
|
+
client._clientLog.warn(`state pull-back failed group=%s:${groupId} ${exc}`);
|
|
437
|
+
}
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const members = membershipSnapshot ? JSON.parse(membershipSnapshot) : [];
|
|
441
|
+
const policy = policySnapshot ? JSON.parse(policySnapshot) : {};
|
|
442
|
+
const computed = await computeStateHash({
|
|
443
|
+
groupId, stateVersion, keyEpoch,
|
|
444
|
+
members, policy, prevStateHash,
|
|
445
|
+
});
|
|
446
|
+
if (computed !== stateHash) {
|
|
447
|
+
client._clientLog.warn('[aun_core] state_hash 重算不匹配 group=%s sv=%d expected=%s got=%s', groupId, stateVersion, stateHash, computed);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const saveFn = client._tokenStore.saveGroupState;
|
|
451
|
+
if (saveFn) {
|
|
452
|
+
await saveFn.call(client._tokenStore, groupId, {
|
|
453
|
+
group_id: groupId,
|
|
454
|
+
state_version: stateVersion,
|
|
455
|
+
state_hash: stateHash,
|
|
456
|
+
key_epoch: keyEpoch,
|
|
457
|
+
membership_json: membershipSnapshot,
|
|
458
|
+
policy_json: policySnapshot,
|
|
459
|
+
updated_at: Date.now(),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
client._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms group_id=${groupId}`);
|
|
463
|
+
}
|
|
464
|
+
catch (err) {
|
|
465
|
+
client._clientLog.debug(`_onGroupStateCommitted exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
466
|
+
throw err;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
async autoProposeState(groupId, options) {
|
|
470
|
+
const client = this.client;
|
|
471
|
+
const gid = normalizedGroupId(groupId);
|
|
472
|
+
if (!gid)
|
|
473
|
+
return;
|
|
474
|
+
if (options?.leaderDelay) {
|
|
475
|
+
const shouldContinue = await client._v2AutoProposeLeaderDelay(gid);
|
|
476
|
+
if (!shouldContinue)
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const inflight = client._v2AutoProposeInflight.get(gid);
|
|
480
|
+
if (inflight) {
|
|
481
|
+
client._v2AutoProposePending.add(gid);
|
|
482
|
+
await inflight;
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
let resolveTask;
|
|
486
|
+
let rejectTask;
|
|
487
|
+
const task = new Promise((resolve, reject) => {
|
|
488
|
+
resolveTask = resolve;
|
|
489
|
+
rejectTask = reject;
|
|
490
|
+
});
|
|
491
|
+
client._v2AutoProposeInflight.set(gid, task);
|
|
492
|
+
void (async () => {
|
|
493
|
+
try {
|
|
494
|
+
do {
|
|
495
|
+
client._v2AutoProposePending.delete(gid);
|
|
496
|
+
await client._doV2AutoProposeState(gid);
|
|
497
|
+
} while (client._v2AutoProposePending.delete(gid));
|
|
498
|
+
resolveTask();
|
|
499
|
+
}
|
|
500
|
+
catch (exc) {
|
|
501
|
+
rejectTask(exc);
|
|
502
|
+
}
|
|
503
|
+
finally {
|
|
504
|
+
if (client._v2AutoProposeInflight.get(gid) === task) {
|
|
505
|
+
client._v2AutoProposeInflight.delete(gid);
|
|
506
|
+
}
|
|
507
|
+
client._v2AutoProposePending.delete(gid);
|
|
508
|
+
}
|
|
509
|
+
})();
|
|
510
|
+
try {
|
|
511
|
+
await task;
|
|
512
|
+
}
|
|
513
|
+
finally {
|
|
514
|
+
if (client._v2AutoProposeInflight.get(gid) === task) {
|
|
515
|
+
client._v2AutoProposeInflight.delete(gid);
|
|
516
|
+
}
|
|
517
|
+
client._v2AutoProposePending.delete(gid);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
async leaderDelayMs(input) {
|
|
521
|
+
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)));
|
|
522
|
+
const first32 = new DataView(digest.buffer, digest.byteOffset, digest.byteLength)
|
|
523
|
+
.getUint32(0, false);
|
|
524
|
+
return 2000 + (first32 % 4000);
|
|
525
|
+
}
|
|
526
|
+
async autoProposeLeaderDelay(groupId) {
|
|
527
|
+
const client = this.client;
|
|
528
|
+
try {
|
|
529
|
+
const membersResp = await client.call('group.get_online_members', { group_id: groupId });
|
|
530
|
+
const members = (Array.isArray(membersResp?.members) ? membersResp.members
|
|
531
|
+
: Array.isArray(membersResp?.items) ? membersResp.items
|
|
532
|
+
: Array.isArray(membersResp?.online_members) ? membersResp.online_members : []);
|
|
533
|
+
const myAid = client._aid ?? '';
|
|
534
|
+
let myRole = '';
|
|
535
|
+
const onlineAdminAids = new Set();
|
|
536
|
+
for (const member of members) {
|
|
537
|
+
const aid = String(member.aid ?? '').trim();
|
|
538
|
+
const role = String(member.role ?? '').trim();
|
|
539
|
+
if (!aid)
|
|
540
|
+
continue;
|
|
541
|
+
if ('online' in member && !Boolean(member.online))
|
|
542
|
+
continue;
|
|
543
|
+
if (role === 'owner' || role === 'admin')
|
|
544
|
+
onlineAdminAids.add(aid);
|
|
545
|
+
if (aid === myAid)
|
|
546
|
+
myRole = role;
|
|
547
|
+
}
|
|
548
|
+
if (myRole !== 'owner' && myRole !== 'admin')
|
|
549
|
+
return false;
|
|
550
|
+
const bootstrapResp = await client.call('group.v2.bootstrap', {
|
|
551
|
+
group_id: groupId,
|
|
552
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
553
|
+
});
|
|
554
|
+
const devices = (Array.isArray(bootstrapResp?.devices) ? bootstrapResp.devices : []);
|
|
555
|
+
const candidates = [];
|
|
556
|
+
for (const dev of devices) {
|
|
557
|
+
const aid = String(dev.aid ?? '').trim();
|
|
558
|
+
const hasDeviceId = 'device_id' in dev;
|
|
559
|
+
const deviceId = String(dev.device_id ?? '').trim();
|
|
560
|
+
if (aid && hasDeviceId && onlineAdminAids.has(aid)) {
|
|
561
|
+
candidates.push(`${aid}\x1f${deviceId}`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (candidates.length === 0) {
|
|
565
|
+
for (const aid of [...onlineAdminAids].sort())
|
|
566
|
+
candidates.push(`${aid}\x1f`);
|
|
567
|
+
}
|
|
568
|
+
const myKey = `${myAid}\x1f${client._deviceId ?? ''}`;
|
|
569
|
+
if (!candidates.includes(myKey))
|
|
570
|
+
candidates.push(myKey);
|
|
571
|
+
const leader = [...new Set(candidates)].sort()[0];
|
|
572
|
+
if (leader === myKey) {
|
|
573
|
+
client._clientLog.debug(`V2 auto propose leader elected: group=${groupId} leader=${leader}`);
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
const delayMs = await client._v2LeaderDelayMs(lengthPrefixedTextKey(groupId, myKey));
|
|
577
|
+
client._clientLog.debug(`V2 auto propose non-leader delay: group=${groupId} leader=${leader} self=${myKey} delay_ms=${delayMs}`);
|
|
578
|
+
await client._sleep(delayMs);
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
catch (exc) {
|
|
582
|
+
client._clientLog.debug(`V2 auto propose leader check failed, fallback immediate: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
async verifyCommittedStateBase(groupId, stateResp) {
|
|
587
|
+
const client = this.client;
|
|
588
|
+
const currentSv = Number(stateResp.state_version ?? 0) || 0;
|
|
589
|
+
if (currentSv <= 0)
|
|
590
|
+
return true;
|
|
591
|
+
const currentSh = String(stateResp.state_hash ?? '').trim();
|
|
592
|
+
const membershipSnapshot = String(stateResp.membership_snapshot ?? '').trim();
|
|
593
|
+
if (!currentSh || !membershipSnapshot) {
|
|
594
|
+
client._clientLog.warn(`V2 committed state base incomplete: group=${groupId} sv=${currentSv}`);
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
try {
|
|
598
|
+
const parsed = JSON.parse(membershipSnapshot);
|
|
599
|
+
if (!isJsonObject(parsed)) {
|
|
600
|
+
client._clientLog.warn(`V2 committed state base snapshot is not object: group=${groupId} sv=${currentSv}`);
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
const computed = await computeStateCommitment(groupId, currentSv, parsed);
|
|
604
|
+
if (computed !== currentSh) {
|
|
605
|
+
client._clientLog.warn(`V2 committed state base hash mismatch: group=${groupId} sv=${currentSv}`);
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
catch (exc) {
|
|
611
|
+
client._clientLog.warn(`V2 committed state base verification failed: group=${groupId} sv=${currentSv} err=${formatCaughtError(exc)}`);
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
async doAutoProposeState(groupId) {
|
|
616
|
+
const client = this.client;
|
|
617
|
+
try {
|
|
618
|
+
const membersResp = await client.call('group.get_members', { group_id: groupId });
|
|
619
|
+
const members = (Array.isArray(membersResp?.members) ? membersResp.members
|
|
620
|
+
: Array.isArray(membersResp?.items) ? membersResp.items : []);
|
|
621
|
+
const myAid = client._aid ?? '';
|
|
622
|
+
let myRole = '';
|
|
623
|
+
const memberAids = [];
|
|
624
|
+
const adminAids = [];
|
|
625
|
+
for (const m of members) {
|
|
626
|
+
const aid = String(m.aid ?? '').trim();
|
|
627
|
+
const role = String(m.role ?? '').trim();
|
|
628
|
+
if (aid) {
|
|
629
|
+
memberAids.push(aid);
|
|
630
|
+
if (role === 'owner' || role === 'admin')
|
|
631
|
+
adminAids.push(aid);
|
|
632
|
+
}
|
|
633
|
+
if (aid === myAid)
|
|
634
|
+
myRole = role;
|
|
635
|
+
}
|
|
636
|
+
if (myRole !== 'owner' && myRole !== 'admin')
|
|
637
|
+
return;
|
|
638
|
+
const proposalResp = await client.call('group.v2.get_proposal', { group_id: groupId });
|
|
639
|
+
if (proposalResp && typeof proposalResp === 'object') {
|
|
640
|
+
const pendingProposal = proposalResp.proposal;
|
|
641
|
+
if (pendingProposal && typeof pendingProposal === 'object' && String(pendingProposal.proposal_id ?? '').trim()) {
|
|
642
|
+
const confirmed = await client._v2ConfirmPendingProposal(groupId);
|
|
643
|
+
if (confirmed)
|
|
644
|
+
return;
|
|
645
|
+
const autoConfirmAt = Number(pendingProposal.auto_confirm_at ?? 0) || 0;
|
|
646
|
+
const nowMs = Date.now();
|
|
647
|
+
if (autoConfirmAt > nowMs) {
|
|
648
|
+
const waitMs = Math.min(autoConfirmAt - nowMs + 500, 35000);
|
|
649
|
+
client._clientLog.debug(`V2 auto propose: pending proposal exists, waiting ${waitMs}ms group=${groupId}`);
|
|
650
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const bootstrapResp = await client.call('group.v2.bootstrap', {
|
|
655
|
+
group_id: groupId,
|
|
656
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
657
|
+
});
|
|
658
|
+
const allDevices = (Array.isArray(bootstrapResp?.devices) ? bootstrapResp.devices : []);
|
|
659
|
+
const auditRecipients = (Array.isArray(bootstrapResp?.audit_recipients) ? bootstrapResp.audit_recipients : []);
|
|
660
|
+
const auditAidsList = [...new Set(auditRecipients.map(r => String(r.aid ?? '').trim()).filter(Boolean))].sort();
|
|
661
|
+
const membersWithDevices = {};
|
|
662
|
+
for (const aid of memberAids)
|
|
663
|
+
membersWithDevices[aid] = [];
|
|
664
|
+
for (const dev of allDevices) {
|
|
665
|
+
const devAid = String(dev.aid ?? '').trim();
|
|
666
|
+
if (devAid in membersWithDevices) {
|
|
667
|
+
membersWithDevices[devAid].push({
|
|
668
|
+
device_id: String(dev.device_id ?? ''),
|
|
669
|
+
ik_fp: String(dev.ik_fp ?? ''),
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
const membersPayload = Object.entries(membersWithDevices).map(([aid, devices]) => ({
|
|
674
|
+
aid,
|
|
675
|
+
devices,
|
|
676
|
+
}));
|
|
677
|
+
const statePayload = {
|
|
678
|
+
members: membersPayload,
|
|
679
|
+
audit_aids: auditAidsList,
|
|
680
|
+
admin_set: { admin_aids: adminAids.sort(), threshold: 1 },
|
|
681
|
+
join_policy_hash: null,
|
|
682
|
+
recovery_quorum: null,
|
|
683
|
+
history_policy: 'recent_7_days',
|
|
684
|
+
wrap_protocol: '3DH',
|
|
685
|
+
};
|
|
686
|
+
const stateResp = await client.call('group.get_state', { group_id: groupId });
|
|
687
|
+
if (!stateResp || !isJsonObject(stateResp))
|
|
688
|
+
return;
|
|
689
|
+
if (!(await this.verifyCommittedStateBase(groupId, stateResp)))
|
|
690
|
+
return;
|
|
691
|
+
const currentSv = Number(stateResp.state_version ?? 0);
|
|
692
|
+
const currentSh = String(stateResp.state_hash ?? '');
|
|
693
|
+
const keyEpoch = Number(stateResp.key_epoch ?? 0);
|
|
694
|
+
const stateHash = await computeStateCommitment(groupId, currentSv + 1, statePayload);
|
|
695
|
+
const membershipSnapshot = stableStringify(statePayload);
|
|
696
|
+
const lastMembershipSnapshot = client._v2AutoProposeLastSnapshot.get(groupId);
|
|
697
|
+
if (lastMembershipSnapshot === membershipSnapshot) {
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
const currentMembershipSnapshot = String(stateResp.membership_snapshot ?? '');
|
|
701
|
+
if (currentMembershipSnapshot && currentMembershipSnapshot === membershipSnapshot) {
|
|
702
|
+
client._v2AutoProposeLastSnapshot.set(groupId, membershipSnapshot);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
let signature = '';
|
|
706
|
+
const currentAid = client._currentAid;
|
|
707
|
+
if (currentAid?.privateKeyPem) {
|
|
708
|
+
try {
|
|
709
|
+
const signPayloadObj = {
|
|
710
|
+
group_id: groupId,
|
|
711
|
+
membership_snapshot: membershipSnapshot,
|
|
712
|
+
state_hash: stateHash,
|
|
713
|
+
state_version: currentSv + 1,
|
|
714
|
+
};
|
|
715
|
+
const signPayload = stableStringify(signPayloadObj);
|
|
716
|
+
const signPayloadBytes = new TextEncoder().encode(signPayload);
|
|
717
|
+
const privKey = await importPrivateKeyEcdsa(currentAid.privateKeyPem);
|
|
718
|
+
const sigBytes = await ecdsaSignDer(privKey, signPayloadBytes);
|
|
719
|
+
signature = uint8ToBase64(sigBytes);
|
|
720
|
+
}
|
|
721
|
+
catch (sigExc) {
|
|
722
|
+
client._clientLog.debug(`propose_state signature failed: ${sigExc}`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
const proposeResult = await client.call('group.v2.propose_state', {
|
|
726
|
+
group_id: groupId,
|
|
727
|
+
state_version: currentSv + 1,
|
|
728
|
+
key_epoch: keyEpoch,
|
|
729
|
+
state_hash: stateHash,
|
|
730
|
+
prev_state_hash: currentSh,
|
|
731
|
+
membership_snapshot: membershipSnapshot,
|
|
732
|
+
signature,
|
|
733
|
+
reason: 'membership_changed',
|
|
734
|
+
auto_confirm_seconds: 30,
|
|
735
|
+
});
|
|
736
|
+
client._clientLog.debug(`V2 auto propose_state: group=${groupId} sv=${currentSv + 1}`);
|
|
737
|
+
const proposalId = isJsonObject(proposeResult) ? String(proposeResult.proposal_id ?? '').trim() : '';
|
|
738
|
+
if (proposalId) {
|
|
739
|
+
try {
|
|
740
|
+
await client.call('group.v2.confirm_state', { proposal_id: proposalId });
|
|
741
|
+
client._v2AutoProposeLastSnapshot.set(groupId, membershipSnapshot);
|
|
742
|
+
client._clientLog.debug(`V2 auto confirm_state: group=${groupId} proposal=${proposalId}`);
|
|
743
|
+
}
|
|
744
|
+
catch (confirmExc) {
|
|
745
|
+
client._clientLog.debug(`V2 auto confirm_state failed (non-fatal): group=${groupId} err=${confirmExc}`);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
catch (exc) {
|
|
750
|
+
client._clientLog.debug(`V2 auto propose_state failed (non-fatal): group=${groupId} err=${exc}`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
async verifyPendingProposalAgainstBase(groupId, proposal, stateResp) {
|
|
754
|
+
const client = this.client;
|
|
755
|
+
if (!(await this.verifyCommittedStateBase(groupId, stateResp)))
|
|
756
|
+
return false;
|
|
757
|
+
const currentSv = Number(stateResp.state_version ?? 0) || 0;
|
|
758
|
+
const currentSh = String(stateResp.state_hash ?? '').trim();
|
|
759
|
+
const proposalSv = Number(proposal.state_version ?? 0) || 0;
|
|
760
|
+
const proposalHash = String(proposal.state_hash ?? '').trim();
|
|
761
|
+
const proposalPrev = String(proposal.prev_state_hash ?? '').trim();
|
|
762
|
+
const membershipSnapshot = String(proposal.membership_snapshot ?? '').trim();
|
|
763
|
+
if (proposalSv !== currentSv + 1 || proposalPrev !== currentSh || !proposalHash || !membershipSnapshot) {
|
|
764
|
+
client._clientLog.warn(`V2 pending proposal base mismatch: group=${groupId} current_sv=${currentSv} proposal_sv=${proposalSv}`);
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
767
|
+
try {
|
|
768
|
+
const parsed = JSON.parse(membershipSnapshot);
|
|
769
|
+
if (!isJsonObject(parsed))
|
|
770
|
+
return false;
|
|
771
|
+
const computed = await computeStateCommitment(groupId, proposalSv, parsed);
|
|
772
|
+
if (computed !== proposalHash) {
|
|
773
|
+
client._clientLog.warn(`V2 pending proposal hash mismatch: group=${groupId} proposal_sv=${proposalSv}`);
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
return true;
|
|
777
|
+
}
|
|
778
|
+
catch (exc) {
|
|
779
|
+
client._clientLog.warn(`V2 pending proposal verification failed: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
async confirmPendingProposal(groupId) {
|
|
784
|
+
const client = this.client;
|
|
785
|
+
const proposalResp = await client.call('group.v2.get_proposal', { group_id: groupId });
|
|
786
|
+
const proposal = isJsonObject(proposalResp?.proposal) ? proposalResp.proposal : null;
|
|
787
|
+
const proposalId = proposal ? String(proposal.proposal_id ?? '').trim() : '';
|
|
788
|
+
if (!proposal || !proposalId)
|
|
789
|
+
return false;
|
|
790
|
+
const stateResp = await client.call('group.get_state', { group_id: groupId });
|
|
791
|
+
if (!isJsonObject(stateResp))
|
|
792
|
+
return false;
|
|
793
|
+
const currentSv = Number(stateResp.state_version ?? 0) || 0;
|
|
794
|
+
const proposalSv = Number(proposal.state_version ?? 0) || 0;
|
|
795
|
+
if (proposalSv <= currentSv) {
|
|
796
|
+
client._clientLog.debug(`V2 pending proposal already settled: group=${groupId} current_sv=${currentSv} proposal_sv=${proposalSv}`);
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
if (!(await this.verifyPendingProposalAgainstBase(groupId, proposal, stateResp)))
|
|
800
|
+
return false;
|
|
801
|
+
await client.call('group.v2.confirm_state', { proposal_id: proposalId });
|
|
802
|
+
client._clientLog.info(`V2 confirmed pending proposal: group=${groupId} proposal=${proposalId}`);
|
|
803
|
+
return true;
|
|
804
|
+
}
|
|
805
|
+
async autoConfirmPendingProposals() {
|
|
806
|
+
const client = this.client;
|
|
807
|
+
try {
|
|
808
|
+
const myAid = client._aid ?? '';
|
|
809
|
+
if (!myAid)
|
|
810
|
+
return;
|
|
811
|
+
const groupsResp = await client.call('group.list_my', {});
|
|
812
|
+
const groups = (Array.isArray(groupsResp?.groups) ? groupsResp.groups
|
|
813
|
+
: Array.isArray(groupsResp?.items) ? groupsResp.items : []);
|
|
814
|
+
for (const g of groups) {
|
|
815
|
+
const gid = String(g.group_id ?? '').trim();
|
|
816
|
+
const myRole = String(g.role ?? g.my_role ?? '').trim();
|
|
817
|
+
if (!gid || (myRole !== 'owner' && myRole !== 'admin'))
|
|
818
|
+
continue;
|
|
819
|
+
try {
|
|
820
|
+
const confirmed = await client._v2ConfirmPendingProposal(gid);
|
|
821
|
+
if (!confirmed) {
|
|
822
|
+
await client._v2AutoProposeState(gid);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
catch (exc) {
|
|
826
|
+
client._clientLog.debug(`V2 auto confirm/propose failed (non-fatal): group=${gid} err=${exc}`);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
catch (exc) {
|
|
831
|
+
client._clientLog.debug(`V2 auto confirm pending proposals failed (non-fatal): ${exc}`);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
extractGroupIdFromMutationResult(result, params) {
|
|
835
|
+
const client = this.client;
|
|
836
|
+
const extracted = typeof client._extractGroupIdFromResult === 'function'
|
|
837
|
+
? client._extractGroupIdFromResult(result)
|
|
838
|
+
: '';
|
|
839
|
+
return normalizedGroupId(extracted || params.group_id || '');
|
|
840
|
+
}
|
|
841
|
+
pruneSigCache(now) {
|
|
842
|
+
const client = this.client;
|
|
843
|
+
if (!(client._v2SigCache instanceof Map) || client._v2SigCache.size <= V2_SIG_CACHE_MAX)
|
|
844
|
+
return;
|
|
845
|
+
const stale = [];
|
|
846
|
+
for (const [key, exp] of client._v2SigCache) {
|
|
847
|
+
if (exp <= now)
|
|
848
|
+
stale.push(key);
|
|
849
|
+
}
|
|
850
|
+
for (const key of stale)
|
|
851
|
+
client._v2SigCache.delete(key);
|
|
852
|
+
if (client._v2SigCache.size <= V2_SIG_CACHE_MAX)
|
|
853
|
+
return;
|
|
854
|
+
const entries = [...client._v2SigCache.entries()].sort((a, b) => a[1] - b[1]);
|
|
855
|
+
const evictCount = Math.floor(V2_SIG_CACHE_MAX / 4);
|
|
856
|
+
for (let i = 0; i < evictCount && i < entries.length; i++) {
|
|
857
|
+
client._v2SigCache.delete(entries[i][0]);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
async checkMembershipTamper(groupId, bootstrap, membershipSnapshot) {
|
|
861
|
+
const client = this.client;
|
|
862
|
+
try {
|
|
863
|
+
if (!membershipSnapshot.startsWith('['))
|
|
864
|
+
return;
|
|
865
|
+
const signedSnapshot = JSON.parse(membershipSnapshot);
|
|
866
|
+
if (!Array.isArray(signedSnapshot))
|
|
867
|
+
return;
|
|
868
|
+
const serverMembers = new Set(Array.isArray(bootstrap.member_aids) ? bootstrap.member_aids : []);
|
|
869
|
+
const signedMembers = new Set(signedSnapshot);
|
|
870
|
+
const extra = [];
|
|
871
|
+
for (const m of serverMembers) {
|
|
872
|
+
if (!signedMembers.has(m))
|
|
873
|
+
extra.push(m);
|
|
874
|
+
}
|
|
875
|
+
if (extra.length === 0)
|
|
876
|
+
return;
|
|
877
|
+
let mode = '';
|
|
878
|
+
try {
|
|
879
|
+
const reqResp = await client.call('group.get_join_requirements', { group_id: groupId });
|
|
880
|
+
mode = isJsonObject(reqResp)
|
|
881
|
+
? String(reqResp.mode ?? '').trim()
|
|
882
|
+
: '';
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
mode = '';
|
|
886
|
+
}
|
|
887
|
+
if (mode !== 'open' && mode !== 'invite_code' && mode !== 'invite_only') {
|
|
888
|
+
client._clientLog.warn(`V2 state tamper detected: group=${groupId} pending_extra=${extra.sort().join(',')} mode=${mode}`);
|
|
889
|
+
await client._dispatcher.publish('group.v2.state_tampered', {
|
|
890
|
+
group_id: groupId,
|
|
891
|
+
pending_extra: extra.sort(),
|
|
892
|
+
mode,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
catch {
|
|
897
|
+
// snapshot 解析失败不阻断已完成的签名验证。
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
//# sourceMappingURL=group-state.js.map
|