@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.
- package/dist/auth.js +4 -1
- package/dist/auth.js.map +1 -1
- package/dist/client.d.ts +45 -7
- package/dist/client.js +1021 -180
- package/dist/client.js.map +1 -1
- package/dist/e2ee-group.d.ts +54 -2
- package/dist/e2ee-group.js +314 -36
- package/dist/e2ee-group.js.map +1 -1
- package/dist/e2ee.d.ts +20 -1
- package/dist/e2ee.js +258 -35
- package/dist/e2ee.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/keystore/aid-db.d.ts +10 -0
- package/dist/keystore/aid-db.js +45 -2
- package/dist/keystore/aid-db.js.map +1 -1
- package/dist/keystore/file.d.ts +10 -0
- package/dist/keystore/file.js +73 -0
- package/dist/keystore/file.js.map +1 -1
- package/dist/keystore/index.d.ts +24 -0
- package/dist/namespaces/auth.d.ts +19 -0
- package/dist/namespaces/auth.js +185 -0
- package/dist/namespaces/auth.js.map +1 -1
- package/dist/namespaces/meta.d.ts +75 -0
- package/dist/namespaces/meta.js +464 -0
- package/dist/namespaces/meta.js.map +1 -0
- package/package.json +1 -1
package/dist/e2ee-group.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
}
|
package/dist/e2ee-group.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
990
|
+
// 历史隔离:密钥存储中记录了该 epoch 的成员列表,
|
|
991
|
+
// 若请求者不在该列表中,说明其不属于该 epoch,直接拒绝(不响应)。
|
|
737
992
|
const memberAids = (secretData.member_aids ?? []).map(String).filter(Boolean).sort();
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|