@agentunion/fastaun 0.2.14 → 0.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@
5
5
  * 内置防重放、epoch 降级防护、密钥请求频率限制。
6
6
  */
7
7
  import type { KeyStore } from './keystore/index.js';
8
+ import type { ProtectedHeadersInput } from './e2ee.js';
8
9
  import { type IdentityRecord, type JsonObject, type Message } from './types.js';
9
10
  export interface LoadedGroupSecret {
10
11
  epoch: number;
@@ -16,6 +17,20 @@ export interface LoadedGroupSecret {
16
17
  epoch_chain_unverified?: boolean;
17
18
  epoch_chain_unverified_reason?: string;
18
19
  }
20
+ /**
21
+ * ECIES 加密:P-256 ECDH + HKDF-SHA256 + AES-256-GCM。
22
+ * @param peerPubkeyBytes 65 字节未压缩 P-256 公钥 (0x04 开头)
23
+ * @param plaintext 待加密明文
24
+ * @returns ephemeral_pubkey(65B) || iv(12B) || ciphertext || tag(16B)
25
+ */
26
+ export declare function eciesEncrypt(peerPubkeyBytes: Buffer, plaintext: Buffer): Buffer;
27
+ /**
28
+ * ECIES 解密:对应 eciesEncrypt。
29
+ * @param privateKeyPem 自己的 PEM 格式 EC 私钥
30
+ * @param ciphertext ephemeral_pubkey(65B) || iv(12B) || encrypted || tag(16B)
31
+ * @returns 解密后的明文
32
+ */
33
+ export declare function eciesDecrypt(privateKeyPem: string, ciphertext: Buffer): Buffer;
19
34
  /**
20
35
  * 计算 Epoch Transcript Chain。
21
36
  * genesis(prev_chain=null)使用固定前缀;后续 epoch 使用上一个 chain 的字节。
@@ -37,6 +52,10 @@ export declare function encryptGroupMessage(groupId: string, epoch: number, grou
37
52
  timestamp: number;
38
53
  senderPrivateKeyPem?: string | null;
39
54
  senderCertPem?: string | null;
55
+ protectedHeaders?: ProtectedHeadersInput;
56
+ protected_headers?: ProtectedHeadersInput;
57
+ headers?: ProtectedHeadersInput;
58
+ context?: JsonObject | null;
40
59
  }): JsonObject;
41
60
  /**
42
61
  * 解密群组消息。
@@ -57,6 +76,22 @@ export declare function computeMembershipCommitment(memberAids: string[], epoch:
57
76
  * 2. 检查 myAid 是否在 memberAids 中
58
77
  */
59
78
  export declare function verifyMembershipCommitment(commitment: string, memberAids: string[], epoch: number, groupId: string, myAid: string, groupSecret: Buffer): boolean;
79
+ /**
80
+ * 计算群组状态哈希(链式)。
81
+ * state_hash = SHA-256(group_id | 0x00 | state_version(u64be) | 0x00 |
82
+ * key_epoch(u64be) | 0x00 | membership_block | 0x00 | policy_block | 0x00 | prev_state_hash(32B))
83
+ */
84
+ export declare function computeStateHash(params: {
85
+ groupId: string;
86
+ stateVersion: number;
87
+ keyEpoch: number;
88
+ members: Array<{
89
+ aid: string;
90
+ role: string;
91
+ }>;
92
+ policy: Record<string, unknown>;
93
+ prevStateHash: string;
94
+ }): string;
60
95
  /** 构建 Membership Manifest(未签名) */
61
96
  export declare function buildMembershipManifest(groupId: string, epoch: number, prevEpoch: number | null, memberAids: string[], opts?: {
62
97
  added?: string[];
@@ -148,12 +183,27 @@ export declare class GroupE2EEManager {
148
183
  /** 指定目标 epoch 号轮换(配合服务端 CAS 使用) */
149
184
  rotateEpochTo(groupId: string, newEpoch: number, memberAids: string[], opts?: {
150
185
  rotationId?: string;
186
+ prevChainHint?: string | null;
151
187
  }): JsonObject;
152
188
  discardPendingSecret(groupId: string, epoch: number, rotationId: string): boolean;
153
189
  /** 加密群消息(含发送方签名)。无密钥时抛 E2EEGroupSecretMissingError。 */
154
- encrypt(groupId: string, payload: JsonObject): JsonObject;
190
+ encrypt(groupId: string, payload: JsonObject, opts?: {
191
+ protectedHeaders?: ProtectedHeadersInput;
192
+ protected_headers?: ProtectedHeadersInput;
193
+ headers?: ProtectedHeadersInput;
194
+ context?: JsonObject | null;
195
+ messageId?: string;
196
+ timestamp?: number;
197
+ }): JsonObject;
155
198
  /** 使用指定 epoch 加密群消息。 */
156
- encryptWithEpoch(groupId: string, epoch: number, payload: JsonObject): JsonObject;
199
+ encryptWithEpoch(groupId: string, epoch: number, payload: JsonObject, opts?: {
200
+ protectedHeaders?: ProtectedHeadersInput;
201
+ protected_headers?: ProtectedHeadersInput;
202
+ headers?: ProtectedHeadersInput;
203
+ context?: JsonObject | null;
204
+ messageId?: string;
205
+ timestamp?: number;
206
+ }): JsonObject;
157
207
  /** 解密单条群消息。内置防重放 + 发送方验签。 */
158
208
  decrypt(message: Message, opts?: {
159
209
  skipReplay?: boolean;
@@ -188,5 +238,7 @@ export declare class GroupE2EEManager {
188
238
  cleanExpiredCaches(): void;
189
239
  /** 删除群组的所有本地状态(群组解散时使用) */
190
240
  removeGroup(groupId: string): void;
241
+ /** 加载指定群组的所有 epoch 密钥。返回 Map<epoch, Buffer>。 */
242
+ loadAllSecrets(groupId: string): Map<number, Buffer>;
191
243
  private _currentAid;
192
244
  }
@@ -28,8 +28,71 @@ const AAD_MATCH_FIELDS_GROUP = [
28
28
  'group_id', 'from', 'message_id',
29
29
  'epoch', 'encryption_mode', 'suite',
30
30
  ];
31
+ const AAD_OPTIONAL_FIELDS = [
32
+ 'payload_type', 'protected_headers', 'context_type', 'context_id',
33
+ ];
34
+ const METADATA_AUTH_FIELD = '_auth';
35
+ const METADATA_AUTH_ALG = 'HMAC-SHA256';
36
+ const METADATA_KEY_DOMAIN = Buffer.from('aun-envelope-metadata-key-v1', 'utf-8');
37
+ const PROTECTED_HEADERS_DOMAIN = Buffer.from('aun-protected-headers-v1', 'utf-8');
38
+ const PROTECTED_CONTEXT_DOMAIN = Buffer.from('aun-protected-context-v1', 'utf-8');
31
39
  /** 旧 epoch 默认保留 7 天 */
32
40
  const OLD_EPOCH_RETENTION_SECONDS = 7 * 24 * 3600;
41
+ // ── ECIES (P-256 ECDH + HKDF-SHA256 + AES-256-GCM) ──────────
42
+ const ECIES_HKDF_INFO = Buffer.from('aun-epoch-key-ecies', 'utf-8');
43
+ /**
44
+ * ECIES 加密:P-256 ECDH + HKDF-SHA256 + AES-256-GCM。
45
+ * @param peerPubkeyBytes 65 字节未压缩 P-256 公钥 (0x04 开头)
46
+ * @param plaintext 待加密明文
47
+ * @returns ephemeral_pubkey(65B) || iv(12B) || ciphertext || tag(16B)
48
+ */
49
+ export function eciesEncrypt(peerPubkeyBytes, plaintext) {
50
+ // 生成临时密钥对
51
+ const ephemeral = crypto.createECDH('prime256v1');
52
+ ephemeral.generateKeys();
53
+ const ephemeralPubBytes = ephemeral.getPublicKey(); // 65 字节未压缩
54
+ // ECDH 共享密钥
55
+ const shared = ephemeral.computeSecret(peerPubkeyBytes);
56
+ // HKDF 派生 32 字节 AES 密钥
57
+ const derived = crypto.hkdfSync('sha256', shared, Buffer.alloc(0), ECIES_HKDF_INFO, 32);
58
+ const aesKey = Buffer.from(derived);
59
+ // AES-256-GCM 加密
60
+ const iv = crypto.randomBytes(12);
61
+ const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv);
62
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
63
+ const tag = cipher.getAuthTag(); // 16 字节
64
+ return Buffer.concat([ephemeralPubBytes, iv, encrypted, tag]);
65
+ }
66
+ /**
67
+ * ECIES 解密:对应 eciesEncrypt。
68
+ * @param privateKeyPem 自己的 PEM 格式 EC 私钥
69
+ * @param ciphertext ephemeral_pubkey(65B) || iv(12B) || encrypted || tag(16B)
70
+ * @returns 解密后的明文
71
+ */
72
+ export function eciesDecrypt(privateKeyPem, ciphertext) {
73
+ if (ciphertext.length < 65 + 12 + 16) {
74
+ throw new E2EEError('ECIES ciphertext too short');
75
+ }
76
+ const ephemeralPubBytes = ciphertext.subarray(0, 65);
77
+ const iv = ciphertext.subarray(65, 77);
78
+ const encryptedWithTag = ciphertext.subarray(77);
79
+ const tag = encryptedWithTag.subarray(encryptedWithTag.length - 16);
80
+ const encrypted = encryptedWithTag.subarray(0, encryptedWithTag.length - 16);
81
+ // 从 PEM 私钥创建 ECDH 并计算共享密钥
82
+ const privKeyObj = crypto.createPrivateKey(privateKeyPem);
83
+ const ecdh = crypto.createECDH('prime256v1');
84
+ // 从 JWK 提取私钥 d 值设置到 ECDH
85
+ const jwk = privKeyObj.export({ format: 'jwk' });
86
+ ecdh.setPrivateKey(Buffer.from(jwk.d, 'base64url'));
87
+ const shared = ecdh.computeSecret(ephemeralPubBytes);
88
+ // HKDF 派生 AES 密钥
89
+ const derived = crypto.hkdfSync('sha256', shared, Buffer.alloc(0), ECIES_HKDF_INFO, 32);
90
+ const aesKey = Buffer.from(derived);
91
+ // AES-256-GCM 解密
92
+ const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, iv);
93
+ decipher.setAuthTag(tag);
94
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]);
95
+ }
33
96
  // ── Epoch Transcript Chain ────────────────────────────────────
34
97
  const EPOCH_CHAIN_GENESIS_PREFIX = Buffer.from('aun-epoch-chain:genesis', 'utf-8');
35
98
  /**
@@ -179,17 +242,154 @@ function pemToCertPublicKey(certPem) {
179
242
  return x509.publicKey;
180
243
  }
181
244
  // ── 群组 AAD 工具 ─────────────────────────────────────────────
245
+ function canonicalStringify(value) {
246
+ if (value === null || value === undefined)
247
+ return 'null';
248
+ if (Array.isArray(value)) {
249
+ return `[${value.map(item => canonicalStringify(item)).join(',')}]`;
250
+ }
251
+ if (typeof value === 'object') {
252
+ const record = value;
253
+ const pairs = Object.keys(record)
254
+ .sort()
255
+ .map(key => `${JSON.stringify(key)}:${canonicalStringify(record[key])}`);
256
+ return `{${pairs.join(',')}}`;
257
+ }
258
+ return JSON.stringify(value) ?? 'null';
259
+ }
260
+ function hasOwn(obj, key) {
261
+ return Object.prototype.hasOwnProperty.call(obj, key);
262
+ }
263
+ function normalizeProtectedHeaderKey(key) {
264
+ const value = String(key ?? '').trim().toLowerCase();
265
+ if (!value || !/^[a-z0-9_-]+$/.test(value)) {
266
+ throw new E2EEError('protected header key must match [a-z0-9_-]+');
267
+ }
268
+ if (value === METADATA_AUTH_FIELD) {
269
+ throw new E2EEError('protected header key is reserved');
270
+ }
271
+ return value;
272
+ }
273
+ function normalizeProtectedHeaders(headers) {
274
+ if (headers == null)
275
+ return {};
276
+ const toObject = headers.toObject;
277
+ const raw = typeof toObject === 'function' ? toObject.call(headers) : headers;
278
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
279
+ throw new E2EEError('protected_headers must be an object');
280
+ }
281
+ const result = {};
282
+ for (const [key, value] of Object.entries(raw)) {
283
+ result[normalizeProtectedHeaderKey(key)] = value == null ? '' : String(value);
284
+ }
285
+ return result;
286
+ }
287
+ function copyOptionalEnvelopeMetadata(envelope, messageKey, opts) {
288
+ const payloadType = String(opts?.payloadType ?? '').trim();
289
+ const protectedHeaders = normalizeProtectedHeaders(opts?.protectedHeaders);
290
+ if (payloadType) {
291
+ protectedHeaders.payload_type = payloadType;
292
+ }
293
+ if (Object.keys(protectedHeaders).length > 0) {
294
+ envelope.protected_headers = withMetadataAuth(protectedHeaders, messageKey, PROTECTED_HEADERS_DOMAIN);
295
+ }
296
+ const contextMetadata = normalizeContextMetadata(opts?.context);
297
+ if (Object.keys(contextMetadata).length > 0) {
298
+ envelope.context = withMetadataAuth(contextMetadata, messageKey, PROTECTED_CONTEXT_DOMAIN);
299
+ }
300
+ }
301
+ function metadataBody(metadata) {
302
+ const body = {};
303
+ for (const [key, value] of Object.entries(metadata)) {
304
+ if (key !== METADATA_AUTH_FIELD) {
305
+ body[key] = value;
306
+ }
307
+ }
308
+ return body;
309
+ }
310
+ function metadataAuthTag(key, domain, body) {
311
+ const metadataKey = crypto.createHmac('sha256', key).update(METADATA_KEY_DOMAIN).digest();
312
+ return crypto.createHmac('sha256', metadataKey)
313
+ .update(domain)
314
+ .update(Buffer.from([0]))
315
+ .update(Buffer.from(canonicalStringify(body), 'utf-8'))
316
+ .digest();
317
+ }
318
+ function withMetadataAuth(metadata, key, domain) {
319
+ const body = metadataBody(metadata);
320
+ if (Object.keys(body).length === 0)
321
+ return {};
322
+ const tag = metadataAuthTag(key, domain, body);
323
+ return {
324
+ ...body,
325
+ [METADATA_AUTH_FIELD]: {
326
+ alg: METADATA_AUTH_ALG,
327
+ tag: tag.toString('base64'),
328
+ },
329
+ };
330
+ }
331
+ function verifyMetadataAuth(metadata, key, domain) {
332
+ if (metadata == null)
333
+ return true;
334
+ if (typeof metadata !== 'object' || Array.isArray(metadata))
335
+ return false;
336
+ const record = metadata;
337
+ const auth = record[METADATA_AUTH_FIELD];
338
+ if (!auth || typeof auth !== 'object' || Array.isArray(auth))
339
+ return false;
340
+ const authObj = auth;
341
+ if (authObj.alg !== METADATA_AUTH_ALG)
342
+ return false;
343
+ if (typeof authObj.tag !== 'string' || !authObj.tag)
344
+ return false;
345
+ const body = metadataBody(record);
346
+ if (Object.keys(body).length === 0)
347
+ return false;
348
+ const actual = Buffer.from(authObj.tag, 'base64');
349
+ const expected = metadataAuthTag(key, domain, body);
350
+ return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
351
+ }
352
+ function verifyEnvelopeMetadataAuth(payload, messageKey) {
353
+ return verifyMetadataAuth(payload.protected_headers, messageKey, PROTECTED_HEADERS_DOMAIN)
354
+ && verifyMetadataAuth(payload.context, messageKey, PROTECTED_CONTEXT_DOMAIN);
355
+ }
356
+ function normalizeContextMetadata(context) {
357
+ if (!context || typeof context !== 'object' || Array.isArray(context))
358
+ return {};
359
+ return metadataBody(context);
360
+ }
361
+ function exposedEnvelopeMetadata(metadata) {
362
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata))
363
+ return undefined;
364
+ const body = metadataBody(metadata);
365
+ return Object.keys(body).length > 0 ? body : undefined;
366
+ }
367
+ function validateDecryptedEnvelopeMetadata(decoded, payload, message) {
368
+ if (payload.protected_headers && typeof payload.protected_headers === 'object' && !Array.isArray(payload.protected_headers)) {
369
+ const headers = metadataBody(payload.protected_headers);
370
+ if (hasOwn(headers, 'payload_type')) {
371
+ if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded))
372
+ return false;
373
+ if (String(decoded.type ?? '') !== String(headers.payload_type ?? '')) {
374
+ return false;
375
+ }
376
+ }
377
+ }
378
+ if (payload.context && typeof payload.context === 'object' && !Array.isArray(payload.context)) {
379
+ const protectedContext = metadataBody(payload.context);
380
+ const outerContext = normalizeContextMetadata(message?.context);
381
+ if (canonicalStringify(outerContext) !== canonicalStringify(protectedContext))
382
+ return false;
383
+ }
384
+ return true;
385
+ }
182
386
  /** 群组 AAD 序列化(sorted keys, compact JSON) */
183
387
  function aadBytesGroup(aad) {
184
388
  const obj = {};
185
389
  for (const field of AAD_FIELDS_GROUP) {
186
390
  obj[field] = aad[field] ?? null;
187
391
  }
188
- const sorted = {};
189
- for (const k of Object.keys(obj).sort()) {
190
- sorted[k] = obj[k];
191
- }
192
- return Buffer.from(JSON.stringify(sorted), 'utf-8');
392
+ return Buffer.from(canonicalStringify(obj), 'utf-8');
193
393
  }
194
394
  /** 群组 AAD 字段匹配检查 */
195
395
  function aadMatchesGroup(expected, actual) {
@@ -222,19 +422,24 @@ export function encryptGroupMessage(groupId, epoch, groupSecret, payload, opts)
222
422
  encryption_mode: MODE_EPOCH_GROUP_KEY,
223
423
  suite: SUITE,
224
424
  };
225
- const aadBytes = aadBytesGroup(aad);
226
- const { ciphertext, tag, nonce } = aesGcmEncrypt(msgKey, plaintext, aadBytes);
227
425
  const envelope = {
228
426
  type: 'e2ee.group_encrypted',
229
427
  version: '1',
230
428
  encryption_mode: MODE_EPOCH_GROUP_KEY,
231
429
  suite: SUITE,
232
430
  epoch,
233
- nonce: nonce.toString('base64'),
234
- ciphertext: ciphertext.toString('base64'),
235
- tag: tag.toString('base64'),
236
- aad,
237
431
  };
432
+ copyOptionalEnvelopeMetadata(envelope, msgKey, {
433
+ payloadType: payload.type,
434
+ protectedHeaders: opts.protectedHeaders ?? opts.protected_headers ?? opts.headers,
435
+ context: opts.context ?? null,
436
+ });
437
+ const aadBytes = aadBytesGroup(aad);
438
+ const { ciphertext, tag, nonce } = aesGcmEncrypt(msgKey, plaintext, aadBytes);
439
+ envelope.nonce = nonce.toString('base64');
440
+ envelope.ciphertext = ciphertext.toString('base64');
441
+ envelope.tag = tag.toString('base64');
442
+ envelope.aad = aad;
238
443
  // 发送方签名
239
444
  if (opts.senderPrivateKeyPem) {
240
445
  const signPayload = Buffer.concat([ciphertext, tag, aadBytes]);
@@ -298,19 +503,32 @@ export function decryptGroupMessage(message, groupSecrets, senderCertPem, opts)
298
503
  const nonce = Buffer.from(payload.nonce, 'base64');
299
504
  const ciphertext = Buffer.from(payload.ciphertext, 'base64');
300
505
  const tag = Buffer.from(payload.tag, 'base64');
506
+ if (!verifyEnvelopeMetadataAuth(payload, msgKey)) {
507
+ return null;
508
+ }
301
509
  const aadBytes = aad ? aadBytesGroup(aad) : Buffer.alloc(0);
302
510
  const plaintext = aesGcmDecrypt(msgKey, ciphertext, tag, nonce, aadBytes);
303
511
  const decoded = JSON.parse(plaintext.toString('utf-8'));
512
+ if (!validateDecryptedEnvelopeMetadata(decoded, payload, message)) {
513
+ return null;
514
+ }
515
+ const e2ee = {
516
+ encryption_mode: MODE_EPOCH_GROUP_KEY,
517
+ suite: SUITE,
518
+ epoch,
519
+ sender_verified: false,
520
+ };
521
+ const protectedHeaders = exposedEnvelopeMetadata(payload.protected_headers);
522
+ if (protectedHeaders)
523
+ e2ee.protected_headers = protectedHeaders;
524
+ const context = exposedEnvelopeMetadata(payload.context);
525
+ if (context)
526
+ e2ee.context = context;
304
527
  const result = {
305
528
  ...message,
306
529
  payload: decoded,
307
530
  encrypted: true,
308
- e2ee: {
309
- encryption_mode: MODE_EPOCH_GROUP_KEY,
310
- suite: SUITE,
311
- epoch,
312
- sender_verified: false,
313
- },
531
+ e2ee,
314
532
  };
315
533
  // 发送方签名验证
316
534
  const senderSigB64 = payload.sender_signature;
@@ -382,6 +600,42 @@ export function verifyMembershipCommitment(commitment, memberAids, epoch, groupI
382
600
  return false;
383
601
  return crypto.timingSafeEqual(a, b);
384
602
  }
603
+ // ── State Hash ────────────────────────────────────────────────
604
+ /**
605
+ * 计算群组状态哈希(链式)。
606
+ * state_hash = SHA-256(group_id | 0x00 | state_version(u64be) | 0x00 |
607
+ * key_epoch(u64be) | 0x00 | membership_block | 0x00 | policy_block | 0x00 | prev_state_hash(32B))
608
+ */
609
+ export function computeStateHash(params) {
610
+ const { groupId, stateVersion, keyEpoch, members, policy, prevStateHash } = params;
611
+ // 按 AID 排序,构建 membership_block
612
+ const sorted = [...members].sort((a, b) => a.aid.localeCompare(b.aid));
613
+ const membershipBlock = sorted.map(m => `${m.aid}:${m.role}`).join('|');
614
+ // 按 key 排序的 canonical JSON(无空格)
615
+ const sortedPolicy = {};
616
+ for (const key of Object.keys(policy).sort()) {
617
+ sortedPolicy[key] = policy[key];
618
+ }
619
+ const policyBlock = Object.keys(policy).length > 0 ? JSON.stringify(sortedPolicy) : '';
620
+ // prev_state_hash: 32 字节,空则全零
621
+ const prevBytes = prevStateHash
622
+ ? Buffer.from(prevStateHash, 'hex')
623
+ : Buffer.alloc(32);
624
+ // state_version / key_epoch → uint64 big-endian
625
+ const svBuf = Buffer.alloc(8);
626
+ svBuf.writeBigUInt64BE(BigInt(stateVersion));
627
+ const keBuf = Buffer.alloc(8);
628
+ keBuf.writeBigUInt64BE(BigInt(keyEpoch));
629
+ const data = Buffer.concat([
630
+ Buffer.from(groupId, 'utf-8'), Buffer.from([0x00]),
631
+ svBuf, Buffer.from([0x00]),
632
+ keBuf, Buffer.from([0x00]),
633
+ Buffer.from(membershipBlock, 'utf-8'), Buffer.from([0x00]),
634
+ Buffer.from(policyBlock, 'utf-8'), Buffer.from([0x00]),
635
+ prevBytes,
636
+ ]);
637
+ return crypto.createHash('sha256').update(data).digest('hex');
638
+ }
385
639
  // ── Membership Manifest ───────────────────────────────────────
386
640
  /** 构建 Membership Manifest(未签名) */
387
641
  export function buildMembershipManifest(groupId, epoch, prevEpoch, memberAids, opts) {
@@ -733,19 +987,20 @@ export function handleKeyRequest(request, keystore, aid, currentMembers, private
733
987
  const secretData = loadGroupSecret(keystore, aid, groupId, epoch);
734
988
  if (!secretData)
735
989
  return null;
736
- let commitmentStr = secretData.commitment ?? '';
990
+ // 历史隔离:密钥存储中记录了该 epoch 的成员列表,
991
+ // 若请求者不在该列表中,说明其不属于该 epoch,直接拒绝(不响应)。
737
992
  const memberAids = (secretData.member_aids ?? []).map(String).filter(Boolean).sort();
738
- const currentMemberAids = currentMembers.map(String).filter(Boolean).sort();
739
- let responseMemberAids = memberAids.length > 0 ? memberAids : currentMemberAids;
740
- let includeEpochChain = true;
741
- if (currentMemberAids.includes(requesterAid) && !responseMemberAids.includes(requesterAid)) {
742
- responseMemberAids = currentMemberAids;
743
- commitmentStr = computeMembershipCommitment(responseMemberAids, epoch, groupId, secretData.secret);
744
- includeEpochChain = false;
745
- }
746
- else if (!commitmentStr) {
747
- commitmentStr = computeMembershipCommitment(responseMemberAids, epoch, groupId, secretData.secret);
993
+ if (memberAids.length > 0 && !memberAids.includes(requesterAid)) {
994
+ return null;
748
995
  }
996
+ // 确定响应使用的成员列表:优先使用密钥存储中的成员列表,
997
+ // 若为空(旧数据),则降级使用当前成员列表。
998
+ const responseMemberAids = memberAids.length > 0
999
+ ? memberAids
1000
+ : currentMembers.map(String).filter(Boolean).sort();
1001
+ // 若存储中无 commitment,按当前成员列表重新计算
1002
+ const commitmentStr = secretData.commitment
1003
+ || computeMembershipCommitment(responseMemberAids, epoch, groupId, secretData.secret);
749
1004
  const response = {
750
1005
  type: 'e2ee.group_key_response',
751
1006
  group_id: groupId,
@@ -758,7 +1013,8 @@ export function handleKeyRequest(request, keystore, aid, currentMembers, private
758
1013
  responder_aid: aid,
759
1014
  issued_at: Date.now(),
760
1015
  };
761
- if (includeEpochChain && secretData.epoch_chain !== undefined) {
1016
+ // epoch_chain 始终包含(若有),供接收方验证历史轮转链
1017
+ if (secretData.epoch_chain !== undefined) {
762
1018
  response.epoch_chain = secretData.epoch_chain;
763
1019
  }
764
1020
  return privateKeyPem ? signGroupKeyResponse(response, privateKeyPem) : response;
@@ -806,6 +1062,17 @@ export function handleKeyResponse(response, keystore, aid, opts) {
806
1062
  if (!verifyMembershipCommitment(commitment, memberAids, epoch, groupId, aid, groupSecret)) {
807
1063
  return false;
808
1064
  }
1065
+ const manifest = isJsonObject(payload.manifest) ? payload.manifest : null;
1066
+ if (manifest) {
1067
+ if (manifest.group_id !== groupId || manifest.epoch !== epoch)
1068
+ return false;
1069
+ const manifestMembers = Array.isArray(manifest.member_aids)
1070
+ ? manifest.member_aids.map((item) => String(item ?? '').trim()).filter(Boolean).sort()
1071
+ : [];
1072
+ const payloadMembers = memberAids.map((item) => String(item ?? '').trim()).filter(Boolean).sort();
1073
+ if (manifestMembers.length > 0 && manifestMembers.join('\n') !== payloadMembers.join('\n'))
1074
+ return false;
1075
+ }
809
1076
  const incomingChain = typeof payload.epoch_chain === 'string' ? payload.epoch_chain : undefined;
810
1077
  const rotationId = typeof payload.rotation_id === 'string' ? payload.rotation_id : '';
811
1078
  const chainAssessment = assessIncomingEpochChain(keystore, aid, groupId, epoch, commitment, incomingChain, rotationId, String(payload.distributed_by ?? payload.rotator_aid ?? payload.responder_aid ?? ''), 'key_response');
@@ -890,7 +1157,10 @@ export class GroupE2EEManager {
890
1157
  ?? loadGroupSecret(this._keystore, aid, groupId);
891
1158
  const gs = generateGroupSecret();
892
1159
  const commitment = computeMembershipCommitment(memberAids, newEpoch, groupId, gs);
893
- const prevChain = current?.epoch_chain ?? null;
1160
+ let prevChain = current?.epoch_chain ?? null;
1161
+ if (!prevChain && opts?.prevChainHint) {
1162
+ prevChain = opts.prevChainHint;
1163
+ }
894
1164
  const epochChain = computeEpochChain(prevChain, newEpoch, commitment, aid);
895
1165
  const rotationId = opts?.rotationId ?? '';
896
1166
  const stored = storeGroupSecret(this._keystore, aid, groupId, newEpoch, gs, commitment, memberAids, epochChain, rotationId);
@@ -914,7 +1184,7 @@ export class GroupE2EEManager {
914
1184
  return discardPendingGroupSecret(this._keystore, this._currentAid(), groupId, epoch, rotationId);
915
1185
  }
916
1186
  /** 加密群消息(含发送方签名)。无密钥时抛 E2EEGroupSecretMissingError。 */
917
- encrypt(groupId, payload) {
1187
+ encrypt(groupId, payload, opts) {
918
1188
  const aid = this._currentAid();
919
1189
  const secretData = loadGroupSecret(this._keystore, aid, groupId);
920
1190
  if (!secretData) {
@@ -929,14 +1199,16 @@ export class GroupE2EEManager {
929
1199
  const senderCertPem = identity ? identity.cert ?? null : null;
930
1200
  return encryptGroupMessage(groupId, secretData.epoch, secretData.secret, payload, {
931
1201
  fromAid: aid,
932
- messageId: `gm-${crypto.randomUUID()}`,
933
- timestamp: Date.now(),
1202
+ messageId: opts?.messageId ?? `gm-${crypto.randomUUID()}`,
1203
+ timestamp: opts?.timestamp ?? Date.now(),
934
1204
  senderPrivateKeyPem: senderPkPem,
935
1205
  senderCertPem,
1206
+ protectedHeaders: opts?.protectedHeaders ?? opts?.protected_headers ?? opts?.headers,
1207
+ context: opts?.context ?? null,
936
1208
  });
937
1209
  }
938
1210
  /** 使用指定 epoch 加密群消息。 */
939
- encryptWithEpoch(groupId, epoch, payload) {
1211
+ encryptWithEpoch(groupId, epoch, payload, opts) {
940
1212
  const aid = this._currentAid();
941
1213
  const secretData = loadGroupSecret(this._keystore, aid, groupId, epoch);
942
1214
  if (!secretData) {
@@ -950,10 +1222,12 @@ export class GroupE2EEManager {
950
1222
  const senderCertPem = identity ? identity.cert ?? null : null;
951
1223
  return encryptGroupMessage(groupId, secretData.epoch, secretData.secret, payload, {
952
1224
  fromAid: aid,
953
- messageId: `gm-${crypto.randomUUID()}`,
954
- timestamp: Date.now(),
1225
+ messageId: opts?.messageId ?? `gm-${crypto.randomUUID()}`,
1226
+ timestamp: opts?.timestamp ?? Date.now(),
955
1227
  senderPrivateKeyPem: senderPkPem,
956
1228
  senderCertPem,
1229
+ protectedHeaders: opts?.protectedHeaders ?? opts?.protected_headers ?? opts?.headers,
1230
+ context: opts?.context ?? null,
957
1231
  });
958
1232
  }
959
1233
  /** 解密单条群消息。内置防重放 + 发送方验签。 */
@@ -1123,6 +1397,10 @@ export class GroupE2EEManager {
1123
1397
  // keystore 不支持 delete 时忽略(降级方案已在 deleteKeyStoreGroupState 中处理)
1124
1398
  }
1125
1399
  }
1400
+ /** 加载指定群组的所有 epoch 密钥。返回 Map<epoch, Buffer>。 */
1401
+ loadAllSecrets(groupId) {
1402
+ return loadAllGroupSecrets(this._keystore, this._currentAid(), groupId);
1403
+ }
1126
1404
  _currentAid() {
1127
1405
  const identity = this._identityFn();
1128
1406
  const aid = identity.aid;