@immahq/aegis 0.0.1
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/LICENSE +21 -0
- package/README.md +257 -0
- package/dist/constants.js +42 -0
- package/dist/crypto-manager.js +306 -0
- package/dist/e2ee.js +207 -0
- package/dist/group-manager.js +606 -0
- package/dist/identity-manager.js +112 -0
- package/dist/index.js +11 -0
- package/dist/logger.js +23 -0
- package/dist/prekey-manager.js +63 -0
- package/dist/ratchet-manager.js +130 -0
- package/dist/ratchet.js +168 -0
- package/dist/replay-protection.js +37 -0
- package/dist/session-manager.js +177 -0
- package/dist/session.js +131 -0
- package/dist/storage.js +54 -0
- package/dist/types.js +1 -0
- package/dist/utils.js +27 -0
- package/package.json +54 -0
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
|
|
2
|
+
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
|
|
3
|
+
import { blake3 } from "@noble/hashes/blake3.js";
|
|
4
|
+
import { randomBytes } from "@noble/post-quantum/utils.js";
|
|
5
|
+
import { bytesToHex, concatBytes, utf8ToBytes } from "@noble/hashes/utils.js";
|
|
6
|
+
import { Logger } from "./logger.js";
|
|
7
|
+
import { MAX_MESSAGE_AGE } from "./constants.js";
|
|
8
|
+
export class GroupManager {
|
|
9
|
+
constructor(storage) {
|
|
10
|
+
Object.defineProperty(this, "storage", {
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: true,
|
|
13
|
+
writable: true,
|
|
14
|
+
value: void 0
|
|
15
|
+
});
|
|
16
|
+
Object.defineProperty(this, "identity", {
|
|
17
|
+
enumerable: true,
|
|
18
|
+
configurable: true,
|
|
19
|
+
writable: true,
|
|
20
|
+
value: null
|
|
21
|
+
});
|
|
22
|
+
Object.defineProperty(this, "sentMessageNumbers", {
|
|
23
|
+
enumerable: true,
|
|
24
|
+
configurable: true,
|
|
25
|
+
writable: true,
|
|
26
|
+
value: void 0
|
|
27
|
+
}); // groupId -> senderId -> messageNumber
|
|
28
|
+
this.storage = storage;
|
|
29
|
+
this.sentMessageNumbers = new Map();
|
|
30
|
+
}
|
|
31
|
+
async initialize(identity) {
|
|
32
|
+
this.identity = identity;
|
|
33
|
+
}
|
|
34
|
+
async createGroup(name, members, memberKemPublicKeys, memberDsaPublicKeys) {
|
|
35
|
+
if (!this.identity) {
|
|
36
|
+
throw new Error("GroupManager not initialized with identity");
|
|
37
|
+
}
|
|
38
|
+
if (members.length < 2) {
|
|
39
|
+
throw new Error("Group must have at least 2 members");
|
|
40
|
+
}
|
|
41
|
+
// Generate a random shared key for the group
|
|
42
|
+
const sharedKey = randomBytes(32);
|
|
43
|
+
// Create group ID based on name and members
|
|
44
|
+
const groupId = "GROUP_" +
|
|
45
|
+
bytesToHex(blake3(concatBytes(utf8ToBytes(name), ...members.map((m) => utf8ToBytes(String(m)))), { dkLen: 32 }));
|
|
46
|
+
// Check if group already exists in storage
|
|
47
|
+
const existingGroup = await this.storage.getSession(groupId);
|
|
48
|
+
if (existingGroup) {
|
|
49
|
+
throw new Error("Group already exists");
|
|
50
|
+
}
|
|
51
|
+
// Create member keys - encrypt the shared key for each member
|
|
52
|
+
const memberKeys = new Map();
|
|
53
|
+
const memberPublicKeysMap = new Map();
|
|
54
|
+
const memberDsaPublicKeysMap = new Map();
|
|
55
|
+
// Use the provided member public keys
|
|
56
|
+
for (const memberId of members) {
|
|
57
|
+
const kemPublicKey = memberKemPublicKeys.get(memberId);
|
|
58
|
+
if (!kemPublicKey) {
|
|
59
|
+
throw new Error(`KEM public key not provided for member: ${memberId}`);
|
|
60
|
+
}
|
|
61
|
+
memberPublicKeysMap.set(memberId, kemPublicKey);
|
|
62
|
+
const dsaPublicKey = memberDsaPublicKeys.get(memberId);
|
|
63
|
+
if (!dsaPublicKey) {
|
|
64
|
+
throw new Error(`DSA public key not provided for member: ${memberId}`);
|
|
65
|
+
}
|
|
66
|
+
memberDsaPublicKeysMap.set(memberId, dsaPublicKey);
|
|
67
|
+
// Encrypt the shared key with the member's KEM public key using ML-KEM
|
|
68
|
+
const encryptedSharedKey = await this.encryptKeyWithPublicKey(sharedKey, kemPublicKey);
|
|
69
|
+
memberKeys.set(memberId, encryptedSharedKey);
|
|
70
|
+
}
|
|
71
|
+
const group = {
|
|
72
|
+
groupId,
|
|
73
|
+
name,
|
|
74
|
+
members,
|
|
75
|
+
sharedKey, // This is the actual shared key for the group owner
|
|
76
|
+
createdAt: Date.now(),
|
|
77
|
+
lastUpdated: Date.now(),
|
|
78
|
+
owner: this.identity.userId,
|
|
79
|
+
memberKeys, // These are encrypted with each member's KEM public key
|
|
80
|
+
memberPublicKeys: memberPublicKeysMap,
|
|
81
|
+
memberDsaPublicKeys: memberDsaPublicKeysMap, // DSA public keys for signature verification
|
|
82
|
+
receivedMessageNumbers: new Map(), // Initialize received message numbers tracking
|
|
83
|
+
};
|
|
84
|
+
// Store the group in the storage with a special format
|
|
85
|
+
// We'll store it as a session with additional group-specific data
|
|
86
|
+
await this.storage.saveSession(groupId, {
|
|
87
|
+
sessionId: groupId,
|
|
88
|
+
peerUserId: "GROUP", // Special marker for group sessions
|
|
89
|
+
peerDsaPublicKey: this.identity.dsaKeyPair.publicKey,
|
|
90
|
+
rootKey: group.sharedKey, // The actual shared key for the group owner
|
|
91
|
+
currentRatchetKeyPair: null,
|
|
92
|
+
peerRatchetPublicKey: null,
|
|
93
|
+
sendingChain: null,
|
|
94
|
+
receivingChain: null,
|
|
95
|
+
previousSendingChainLength: 0,
|
|
96
|
+
skippedMessageKeys: new Map(),
|
|
97
|
+
highestReceivedMessageNumber: -1,
|
|
98
|
+
maxSkippedMessages: 100,
|
|
99
|
+
createdAt: group.createdAt,
|
|
100
|
+
lastUsed: Date.now(),
|
|
101
|
+
isInitiator: true,
|
|
102
|
+
ratchetCount: 0,
|
|
103
|
+
state: "ACTIVE",
|
|
104
|
+
confirmed: true,
|
|
105
|
+
// Store group-specific data in additional fields
|
|
106
|
+
groupData: {
|
|
107
|
+
name: group.name,
|
|
108
|
+
members: group.members,
|
|
109
|
+
owner: group.owner,
|
|
110
|
+
memberKeys: Array.from(group.memberKeys.entries()), // Encrypted keys
|
|
111
|
+
memberPublicKeys: Array.from(group.memberPublicKeys.entries()),
|
|
112
|
+
memberDsaPublicKeys: Array.from(group.memberDsaPublicKeys.entries()),
|
|
113
|
+
receivedMessageNumbers: Array.from(group.receivedMessageNumbers.entries()),
|
|
114
|
+
},
|
|
115
|
+
receivedMessageIds: new Set(),
|
|
116
|
+
replayWindowSize: 100,
|
|
117
|
+
lastProcessedTimestamp: Date.now(),
|
|
118
|
+
});
|
|
119
|
+
Logger.log("GroupManager", "Group created successfully", {
|
|
120
|
+
groupId: groupId.substring(0, 16) + "...",
|
|
121
|
+
name,
|
|
122
|
+
membersCount: members.length,
|
|
123
|
+
});
|
|
124
|
+
return group;
|
|
125
|
+
}
|
|
126
|
+
async addMember(groupId, userId, _session, // Unused parameter, using underscore prefix
|
|
127
|
+
userPublicKey // New parameter for the user's public key
|
|
128
|
+
) {
|
|
129
|
+
if (!this.identity) {
|
|
130
|
+
throw new Error("GroupManager not initialized with identity");
|
|
131
|
+
}
|
|
132
|
+
const group = await this.getGroup(groupId);
|
|
133
|
+
if (!group) {
|
|
134
|
+
throw new Error("Group not found");
|
|
135
|
+
}
|
|
136
|
+
// Only owner can add members
|
|
137
|
+
if (group.owner !== this.identity.userId) {
|
|
138
|
+
throw new Error("Only group owner can add members");
|
|
139
|
+
}
|
|
140
|
+
// Check if user is already a member
|
|
141
|
+
if (group.members.includes(userId)) {
|
|
142
|
+
throw new Error("User is already a member of this group");
|
|
143
|
+
}
|
|
144
|
+
// Add user to members list
|
|
145
|
+
group.members.push(userId);
|
|
146
|
+
group.lastUpdated = Date.now();
|
|
147
|
+
// Encrypt the shared key with the new member's public key
|
|
148
|
+
const encryptedSharedKey = await this.encryptKeyWithPublicKey(group.sharedKey, userPublicKey);
|
|
149
|
+
group.memberKeys.set(userId, encryptedSharedKey);
|
|
150
|
+
// Update member public keys with the provided public key
|
|
151
|
+
group.memberPublicKeys.set(userId, userPublicKey);
|
|
152
|
+
// Save updated group to storage
|
|
153
|
+
await this.storage.saveSession(groupId, {
|
|
154
|
+
sessionId: groupId,
|
|
155
|
+
peerUserId: "GROUP", // Special marker for group sessions
|
|
156
|
+
peerDsaPublicKey: this.identity.dsaKeyPair.publicKey,
|
|
157
|
+
rootKey: group.sharedKey,
|
|
158
|
+
currentRatchetKeyPair: null,
|
|
159
|
+
peerRatchetPublicKey: null,
|
|
160
|
+
sendingChain: null,
|
|
161
|
+
receivingChain: null,
|
|
162
|
+
previousSendingChainLength: 0,
|
|
163
|
+
skippedMessageKeys: new Map(),
|
|
164
|
+
highestReceivedMessageNumber: -1,
|
|
165
|
+
maxSkippedMessages: 100,
|
|
166
|
+
createdAt: group.createdAt,
|
|
167
|
+
lastUsed: Date.now(),
|
|
168
|
+
isInitiator: true,
|
|
169
|
+
ratchetCount: 0,
|
|
170
|
+
state: "ACTIVE",
|
|
171
|
+
confirmed: true,
|
|
172
|
+
// Store group-specific data in additional fields
|
|
173
|
+
groupData: {
|
|
174
|
+
name: group.name,
|
|
175
|
+
members: group.members,
|
|
176
|
+
owner: group.owner,
|
|
177
|
+
memberKeys: Array.from(group.memberKeys.entries()),
|
|
178
|
+
memberPublicKeys: Array.from(group.memberPublicKeys.entries()),
|
|
179
|
+
memberDsaPublicKeys: Array.from(group.memberDsaPublicKeys.entries()),
|
|
180
|
+
receivedMessageNumbers: Array.from(group.receivedMessageNumbers.entries()),
|
|
181
|
+
},
|
|
182
|
+
receivedMessageIds: new Set(),
|
|
183
|
+
replayWindowSize: 100,
|
|
184
|
+
lastProcessedTimestamp: Date.now(),
|
|
185
|
+
});
|
|
186
|
+
Logger.log("GroupManager", "Member added to group", {
|
|
187
|
+
groupId: groupId.substring(0, 16) + "...",
|
|
188
|
+
userId,
|
|
189
|
+
membersCount: group.members.length,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
async removeMember(groupId, userId) {
|
|
193
|
+
if (!this.identity) {
|
|
194
|
+
throw new Error("GroupManager not initialized with identity");
|
|
195
|
+
}
|
|
196
|
+
const group = await this.getGroup(groupId);
|
|
197
|
+
if (!group) {
|
|
198
|
+
throw new Error("Group not found");
|
|
199
|
+
}
|
|
200
|
+
// Only owner can remove members
|
|
201
|
+
if (group.owner !== this.identity.userId) {
|
|
202
|
+
throw new Error("Only group owner can remove members");
|
|
203
|
+
}
|
|
204
|
+
// Check if user is a member
|
|
205
|
+
const memberIndex = group.members.indexOf(userId);
|
|
206
|
+
if (memberIndex === -1) {
|
|
207
|
+
throw new Error("User is not a member of this group");
|
|
208
|
+
}
|
|
209
|
+
// Remove user from members list
|
|
210
|
+
group.members.splice(memberIndex, 1);
|
|
211
|
+
group.lastUpdated = Date.now();
|
|
212
|
+
// Remove member key
|
|
213
|
+
group.memberKeys.delete(userId);
|
|
214
|
+
// Remove member public key
|
|
215
|
+
group.memberPublicKeys.delete(userId);
|
|
216
|
+
// Save updated group to storage
|
|
217
|
+
await this.storage.saveSession(groupId, {
|
|
218
|
+
sessionId: groupId,
|
|
219
|
+
peerUserId: "GROUP", // Special marker for group sessions
|
|
220
|
+
peerDsaPublicKey: this.identity.dsaKeyPair.publicKey,
|
|
221
|
+
rootKey: group.sharedKey,
|
|
222
|
+
currentRatchetKeyPair: null,
|
|
223
|
+
peerRatchetPublicKey: null,
|
|
224
|
+
sendingChain: null,
|
|
225
|
+
receivingChain: null,
|
|
226
|
+
previousSendingChainLength: 0,
|
|
227
|
+
skippedMessageKeys: new Map(),
|
|
228
|
+
highestReceivedMessageNumber: -1,
|
|
229
|
+
maxSkippedMessages: 100,
|
|
230
|
+
createdAt: group.createdAt,
|
|
231
|
+
lastUsed: Date.now(),
|
|
232
|
+
isInitiator: true,
|
|
233
|
+
ratchetCount: 0,
|
|
234
|
+
state: "ACTIVE",
|
|
235
|
+
confirmed: true,
|
|
236
|
+
// Store group-specific data in additional fields
|
|
237
|
+
groupData: {
|
|
238
|
+
name: group.name,
|
|
239
|
+
members: group.members,
|
|
240
|
+
owner: group.owner,
|
|
241
|
+
memberKeys: Array.from(group.memberKeys.entries()),
|
|
242
|
+
memberPublicKeys: Array.from(group.memberPublicKeys.entries()),
|
|
243
|
+
memberDsaPublicKeys: Array.from(group.memberDsaPublicKeys.entries()),
|
|
244
|
+
receivedMessageNumbers: Array.from(group.receivedMessageNumbers.entries()),
|
|
245
|
+
},
|
|
246
|
+
receivedMessageIds: new Set(),
|
|
247
|
+
replayWindowSize: 100,
|
|
248
|
+
lastProcessedTimestamp: Date.now(),
|
|
249
|
+
});
|
|
250
|
+
Logger.log("GroupManager", "Member removed from group", {
|
|
251
|
+
groupId: groupId.substring(0, 16) + "...",
|
|
252
|
+
userId,
|
|
253
|
+
membersCount: group.members.length,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
async updateGroupKey(groupId) {
|
|
257
|
+
if (!this.identity) {
|
|
258
|
+
throw new Error("GroupManager not initialized with identity");
|
|
259
|
+
}
|
|
260
|
+
const group = await this.getGroup(groupId);
|
|
261
|
+
if (!group) {
|
|
262
|
+
throw new Error("Group not found");
|
|
263
|
+
}
|
|
264
|
+
// Only owner can update group key
|
|
265
|
+
if (group.owner !== this.identity.userId) {
|
|
266
|
+
throw new Error("Only group owner can update group key");
|
|
267
|
+
}
|
|
268
|
+
// Generate new shared key
|
|
269
|
+
const newSharedKey = randomBytes(32);
|
|
270
|
+
// Update member keys for all members - encrypt the new key with each member's public key
|
|
271
|
+
for (const memberId of group.members) {
|
|
272
|
+
const memberPublicKey = group.memberPublicKeys.get(memberId);
|
|
273
|
+
if (!memberPublicKey) {
|
|
274
|
+
throw new Error(`Public key not found for member: ${memberId}`);
|
|
275
|
+
}
|
|
276
|
+
// Encrypt the new shared key with the member's public key
|
|
277
|
+
const encryptedNewSharedKey = await this.encryptKeyWithPublicKey(newSharedKey, memberPublicKey);
|
|
278
|
+
group.memberKeys.set(memberId, encryptedNewSharedKey);
|
|
279
|
+
}
|
|
280
|
+
// Note: We don't update public keys when updating the group key
|
|
281
|
+
// Update the group shared key
|
|
282
|
+
group.sharedKey = newSharedKey;
|
|
283
|
+
group.lastUpdated = Date.now();
|
|
284
|
+
// Save updated group to storage
|
|
285
|
+
await this.storage.saveSession(groupId, {
|
|
286
|
+
sessionId: groupId,
|
|
287
|
+
peerUserId: "GROUP", // Special marker for group sessions
|
|
288
|
+
peerDsaPublicKey: this.identity.dsaKeyPair.publicKey,
|
|
289
|
+
rootKey: group.sharedKey,
|
|
290
|
+
currentRatchetKeyPair: null,
|
|
291
|
+
peerRatchetPublicKey: null,
|
|
292
|
+
sendingChain: null,
|
|
293
|
+
receivingChain: null,
|
|
294
|
+
previousSendingChainLength: 0,
|
|
295
|
+
skippedMessageKeys: new Map(),
|
|
296
|
+
highestReceivedMessageNumber: -1,
|
|
297
|
+
maxSkippedMessages: 100,
|
|
298
|
+
createdAt: group.createdAt,
|
|
299
|
+
lastUsed: Date.now(),
|
|
300
|
+
isInitiator: true,
|
|
301
|
+
ratchetCount: 0,
|
|
302
|
+
state: "ACTIVE",
|
|
303
|
+
confirmed: true,
|
|
304
|
+
// Store group-specific data in additional fields
|
|
305
|
+
groupData: {
|
|
306
|
+
name: group.name,
|
|
307
|
+
members: group.members,
|
|
308
|
+
owner: group.owner,
|
|
309
|
+
memberKeys: Array.from(group.memberKeys.entries()),
|
|
310
|
+
memberPublicKeys: Array.from(group.memberPublicKeys.entries()),
|
|
311
|
+
memberDsaPublicKeys: Array.from(group.memberDsaPublicKeys.entries()),
|
|
312
|
+
receivedMessageNumbers: Array.from(group.receivedMessageNumbers.entries()),
|
|
313
|
+
},
|
|
314
|
+
receivedMessageIds: new Set(),
|
|
315
|
+
replayWindowSize: 100,
|
|
316
|
+
lastProcessedTimestamp: Date.now(),
|
|
317
|
+
});
|
|
318
|
+
Logger.log("GroupManager", "Group key updated", {
|
|
319
|
+
groupId: groupId.substring(0, 16) + "...",
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
async encryptMessage(groupId, message) {
|
|
323
|
+
if (!this.identity) {
|
|
324
|
+
throw new Error("GroupManager not initialized with identity");
|
|
325
|
+
}
|
|
326
|
+
const group = await this.getGroup(groupId);
|
|
327
|
+
if (!group) {
|
|
328
|
+
throw new Error("Group not found");
|
|
329
|
+
}
|
|
330
|
+
// Check if user is a member of the group
|
|
331
|
+
if (!group.members.includes(this.identity.userId)) {
|
|
332
|
+
throw new Error("User is not a member of this group");
|
|
333
|
+
}
|
|
334
|
+
const messageBytes = typeof message === "string" ? utf8ToBytes(message) : message;
|
|
335
|
+
// Get the group's actual shared key for encryption
|
|
336
|
+
// For the group owner, they have the key directly
|
|
337
|
+
// For other members, they would need to decrypt their encrypted key
|
|
338
|
+
let encryptionKey;
|
|
339
|
+
if (group.owner === this.identity.userId) {
|
|
340
|
+
// Group owner has the key directly
|
|
341
|
+
encryptionKey = group.sharedKey;
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
// Other members need to decrypt their copy of the key
|
|
345
|
+
const encryptedSharedKey = group.memberKeys.get(this.identity.userId);
|
|
346
|
+
if (!encryptedSharedKey) {
|
|
347
|
+
throw new Error("No encrypted shared key found for user");
|
|
348
|
+
}
|
|
349
|
+
if (!this.identity.kemKeyPair.secretKey) {
|
|
350
|
+
throw new Error("User's secret key not available");
|
|
351
|
+
}
|
|
352
|
+
encryptionKey = await this.decryptKeyWithSecretKey(encryptedSharedKey, this.identity.kemKeyPair.secretKey);
|
|
353
|
+
}
|
|
354
|
+
// Encrypt the message with the shared key
|
|
355
|
+
const nonce = randomBytes(24);
|
|
356
|
+
const cipher = xchacha20poly1305(encryptionKey, nonce);
|
|
357
|
+
const ciphertext = cipher.encrypt(messageBytes);
|
|
358
|
+
const fullCiphertext = concatBytes(nonce, ciphertext);
|
|
359
|
+
// Create message header with a simple incrementing number per sender (in memory, not stored)
|
|
360
|
+
// For a real implementation, you'd want a more robust approach to message numbering
|
|
361
|
+
if (!this.sentMessageNumbers) {
|
|
362
|
+
this.sentMessageNumbers = new Map(); // groupId -> senderId -> messageNumber
|
|
363
|
+
}
|
|
364
|
+
if (!this.sentMessageNumbers.has(groupId)) {
|
|
365
|
+
this.sentMessageNumbers.set(groupId, new Map());
|
|
366
|
+
}
|
|
367
|
+
const groupSentNumbers = this.sentMessageNumbers.get(groupId);
|
|
368
|
+
const currentMessageNumber = (groupSentNumbers.get(this.identity.userId) || 0) + 1;
|
|
369
|
+
groupSentNumbers.set(this.identity.userId, currentMessageNumber);
|
|
370
|
+
// Create message header
|
|
371
|
+
const header = {
|
|
372
|
+
messageId: bytesToHex(blake3(fullCiphertext, { dkLen: 32 })),
|
|
373
|
+
timestamp: Date.now(),
|
|
374
|
+
senderId: this.identity.userId,
|
|
375
|
+
messageNumber: currentMessageNumber,
|
|
376
|
+
};
|
|
377
|
+
// Note: We don't update the stored group state here because sent message numbers
|
|
378
|
+
// are tracked separately per participant
|
|
379
|
+
// Sign the message
|
|
380
|
+
const headerBytes = this.serializeGroupHeader(header);
|
|
381
|
+
const messageToSign = concatBytes(headerBytes, fullCiphertext);
|
|
382
|
+
const signature = ml_dsa65.sign(messageToSign, this.identity.dsaKeyPair.secretKey);
|
|
383
|
+
Logger.log("GroupManager", "Group message encrypted", {
|
|
384
|
+
groupId: groupId.substring(0, 16) + "...",
|
|
385
|
+
messageId: header.messageId.substring(0, 16) + "...",
|
|
386
|
+
senderId: this.identity.userId,
|
|
387
|
+
});
|
|
388
|
+
return {
|
|
389
|
+
groupId,
|
|
390
|
+
message: fullCiphertext,
|
|
391
|
+
header,
|
|
392
|
+
signature,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
async decryptMessage(groupId, encrypted) {
|
|
396
|
+
if (!this.identity) {
|
|
397
|
+
throw new Error("GroupManager not initialized with identity");
|
|
398
|
+
}
|
|
399
|
+
const group = await this.getGroup(groupId);
|
|
400
|
+
if (!group) {
|
|
401
|
+
throw new Error("Group not found");
|
|
402
|
+
}
|
|
403
|
+
// Check if user is a member of the group
|
|
404
|
+
if (!group.members.includes(this.identity.userId)) {
|
|
405
|
+
throw new Error("User is not a member of this group");
|
|
406
|
+
}
|
|
407
|
+
// Get the encrypted shared key for this user and decrypt it
|
|
408
|
+
const encryptedSharedKey = group.memberKeys.get(this.identity.userId);
|
|
409
|
+
if (!encryptedSharedKey) {
|
|
410
|
+
throw new Error("No encrypted shared key found for user");
|
|
411
|
+
}
|
|
412
|
+
// We need the user's KEM secret key to decrypt the shared group key
|
|
413
|
+
// For this demo, we'll assume we have access to the user's identity secret key
|
|
414
|
+
// In a real implementation, this would be securely stored and accessed
|
|
415
|
+
if (!this.identity.kemKeyPair.secretKey) {
|
|
416
|
+
throw new Error("User's secret key not available");
|
|
417
|
+
}
|
|
418
|
+
const sharedKey = await this.decryptKeyWithSecretKey(encryptedSharedKey, this.identity.kemKeyPair.secretKey);
|
|
419
|
+
// Get the sender's public key from the group
|
|
420
|
+
const senderPublicKey = await this.getSenderPublicKey(groupId, encrypted.header.senderId);
|
|
421
|
+
if (!senderPublicKey) {
|
|
422
|
+
throw new Error("Could not retrieve sender's public key");
|
|
423
|
+
}
|
|
424
|
+
// Verify signature
|
|
425
|
+
const headerBytes = this.serializeGroupHeader(encrypted.header);
|
|
426
|
+
const messageToVerify = concatBytes(headerBytes, encrypted.message);
|
|
427
|
+
const isValid = ml_dsa65.verify(encrypted.signature, messageToVerify, senderPublicKey);
|
|
428
|
+
if (!isValid) {
|
|
429
|
+
throw new Error("Invalid message signature");
|
|
430
|
+
}
|
|
431
|
+
// Check message freshness
|
|
432
|
+
const now = Date.now();
|
|
433
|
+
const messageAge = now - encrypted.header.timestamp;
|
|
434
|
+
if (messageAge > MAX_MESSAGE_AGE) {
|
|
435
|
+
throw new Error(`Message too old: ${Math.round(messageAge / 1000)}s`);
|
|
436
|
+
}
|
|
437
|
+
// Check message ordering/replay protection
|
|
438
|
+
const lastMessageNumber = group.receivedMessageNumbers.get(encrypted.header.senderId) || 0;
|
|
439
|
+
if (encrypted.header.messageNumber <= lastMessageNumber) {
|
|
440
|
+
throw new Error(`Message number too low: ${encrypted.header.messageNumber} <= ${lastMessageNumber}`);
|
|
441
|
+
}
|
|
442
|
+
// Update the received message number for this sender
|
|
443
|
+
group.receivedMessageNumbers.set(encrypted.header.senderId, encrypted.header.messageNumber);
|
|
444
|
+
// Update the group in storage with the new received message number
|
|
445
|
+
await this.storage.saveSession(groupId, {
|
|
446
|
+
sessionId: groupId,
|
|
447
|
+
peerUserId: "GROUP", // Special marker for group sessions
|
|
448
|
+
peerDsaPublicKey: this.identity.dsaKeyPair.publicKey,
|
|
449
|
+
rootKey: group.sharedKey,
|
|
450
|
+
currentRatchetKeyPair: null,
|
|
451
|
+
peerRatchetPublicKey: null,
|
|
452
|
+
sendingChain: null,
|
|
453
|
+
receivingChain: null,
|
|
454
|
+
previousSendingChainLength: 0,
|
|
455
|
+
skippedMessageKeys: new Map(),
|
|
456
|
+
highestReceivedMessageNumber: -1,
|
|
457
|
+
maxSkippedMessages: 100,
|
|
458
|
+
createdAt: group.createdAt,
|
|
459
|
+
lastUsed: Date.now(),
|
|
460
|
+
isInitiator: true,
|
|
461
|
+
ratchetCount: 0,
|
|
462
|
+
state: "ACTIVE",
|
|
463
|
+
confirmed: true,
|
|
464
|
+
// Store group-specific data in additional fields
|
|
465
|
+
groupData: {
|
|
466
|
+
name: group.name,
|
|
467
|
+
members: group.members,
|
|
468
|
+
owner: group.owner,
|
|
469
|
+
memberKeys: Array.from(group.memberKeys.entries()),
|
|
470
|
+
memberPublicKeys: Array.from(group.memberPublicKeys.entries()),
|
|
471
|
+
memberDsaPublicKeys: Array.from(group.memberDsaPublicKeys.entries()),
|
|
472
|
+
receivedMessageNumbers: Array.from(group.receivedMessageNumbers.entries()),
|
|
473
|
+
},
|
|
474
|
+
receivedMessageIds: new Set(),
|
|
475
|
+
replayWindowSize: 100,
|
|
476
|
+
lastProcessedTimestamp: Date.now(),
|
|
477
|
+
});
|
|
478
|
+
// Decrypt the message
|
|
479
|
+
const nonce = encrypted.message.slice(0, 24);
|
|
480
|
+
const encryptedData = encrypted.message.slice(24);
|
|
481
|
+
const cipher = xchacha20poly1305(sharedKey, nonce);
|
|
482
|
+
const plaintext = cipher.decrypt(encryptedData);
|
|
483
|
+
Logger.log("GroupManager", "Group message decrypted", {
|
|
484
|
+
groupId: groupId.substring(0, 16) + "...",
|
|
485
|
+
messageId: encrypted.header.messageId.substring(0, 16) + "...",
|
|
486
|
+
senderId: encrypted.header.senderId,
|
|
487
|
+
messageNumber: encrypted.header.messageNumber,
|
|
488
|
+
});
|
|
489
|
+
return plaintext;
|
|
490
|
+
}
|
|
491
|
+
async getGroup(groupId) {
|
|
492
|
+
const session = await this.storage.getSession(groupId);
|
|
493
|
+
if (!session || session.peerUserId !== "GROUP") {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
// Reconstruct the group from the stored session data
|
|
497
|
+
const groupData = session.groupData;
|
|
498
|
+
if (!groupData) {
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
// Convert the stored arrays back to Maps
|
|
502
|
+
const memberKeys = new Map(groupData.memberKeys);
|
|
503
|
+
const memberPublicKeys = new Map(groupData.memberPublicKeys);
|
|
504
|
+
// Initialize received message numbers
|
|
505
|
+
const receivedMessageNumbers = new Map();
|
|
506
|
+
if (groupData.receivedMessageNumbers) {
|
|
507
|
+
for (const [senderId, number] of groupData.receivedMessageNumbers) {
|
|
508
|
+
receivedMessageNumbers.set(senderId, number);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
// Initialize DSA public keys
|
|
512
|
+
const memberDsaPublicKeys = new Map(groupData.memberDsaPublicKeys || []);
|
|
513
|
+
return {
|
|
514
|
+
groupId: session.sessionId,
|
|
515
|
+
name: groupData.name,
|
|
516
|
+
members: groupData.members,
|
|
517
|
+
sharedKey: session.rootKey, // This is the actual shared key for the group owner
|
|
518
|
+
createdAt: session.createdAt,
|
|
519
|
+
lastUpdated: session.lastUsed,
|
|
520
|
+
owner: groupData.owner,
|
|
521
|
+
memberKeys, // These are the encrypted keys for each member
|
|
522
|
+
memberPublicKeys,
|
|
523
|
+
memberDsaPublicKeys,
|
|
524
|
+
receivedMessageNumbers, // This is now always initialized
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
async getGroups() {
|
|
528
|
+
const sessionIds = await this.storage.listSessions();
|
|
529
|
+
const groups = [];
|
|
530
|
+
for (const sessionId of sessionIds) {
|
|
531
|
+
if (sessionId.startsWith("GROUP_")) {
|
|
532
|
+
// Only retrieve group sessions
|
|
533
|
+
const group = await this.getGroup(sessionId);
|
|
534
|
+
if (group) {
|
|
535
|
+
groups.push(group);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return groups;
|
|
540
|
+
}
|
|
541
|
+
async getSenderPublicKey(groupId, senderId) {
|
|
542
|
+
const group = await this.getGroup(groupId);
|
|
543
|
+
if (!group) {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
return group.memberDsaPublicKeys.get(senderId) || null;
|
|
547
|
+
}
|
|
548
|
+
// Encrypt a key with a public key using ML-KEM
|
|
549
|
+
async encryptKeyWithPublicKey(key, publicKey) {
|
|
550
|
+
const { ml_kem768 } = await import("@noble/post-quantum/ml-kem.js");
|
|
551
|
+
const { blake3 } = await import("@noble/hashes/blake3.js");
|
|
552
|
+
const { xchacha20poly1305 } = await import("@noble/ciphers/chacha.js");
|
|
553
|
+
const { randomBytes, concatBytes } = await import("@noble/hashes/utils.js");
|
|
554
|
+
// Use ML-KEM to generate a shared secret with the recipient's public key
|
|
555
|
+
const result = ml_kem768.encapsulate(publicKey);
|
|
556
|
+
const sharedSecret = result.sharedSecret;
|
|
557
|
+
const ciphertext = result.cipherText;
|
|
558
|
+
// Use the shared secret to derive a symmetric key for encrypting the actual key
|
|
559
|
+
const encryptionKey = blake3(sharedSecret, { dkLen: 32 });
|
|
560
|
+
// Generate a random nonce for encryption
|
|
561
|
+
const nonce = randomBytes(24);
|
|
562
|
+
// Encrypt the key using ChaCha20-Poly1305 with the derived key
|
|
563
|
+
const cipher = xchacha20poly1305(encryptionKey, nonce);
|
|
564
|
+
const encryptedKey = cipher.encrypt(key);
|
|
565
|
+
// Return ciphertext (for decapsulation) + nonce + encrypted key
|
|
566
|
+
return concatBytes(ciphertext, nonce, encryptedKey);
|
|
567
|
+
}
|
|
568
|
+
// Decrypt an encrypted key with a secret key using ML-KEM
|
|
569
|
+
async decryptKeyWithSecretKey(encryptedKey, secretKey) {
|
|
570
|
+
const { ml_kem768 } = await import("@noble/post-quantum/ml-kem.js");
|
|
571
|
+
const { blake3 } = await import("@noble/hashes/blake3.js");
|
|
572
|
+
const { xchacha20poly1305 } = await import("@noble/ciphers/chacha.js");
|
|
573
|
+
// Extract components: first ML-KEM ciphertext (1088 bytes for ML-KEM 768)
|
|
574
|
+
// Then 24-byte nonce, then the rest is the encrypted key
|
|
575
|
+
const kemCiphertextLength = 1088; // Length of ML-KEM 768 ciphertext
|
|
576
|
+
const nonceLength = 24;
|
|
577
|
+
if (encryptedKey.length < kemCiphertextLength + nonceLength) {
|
|
578
|
+
throw new Error("Invalid encrypted key format");
|
|
579
|
+
}
|
|
580
|
+
const kemCiphertext = encryptedKey.slice(0, kemCiphertextLength);
|
|
581
|
+
const nonce = encryptedKey.slice(kemCiphertextLength, kemCiphertextLength + nonceLength);
|
|
582
|
+
const encryptedData = encryptedKey.slice(kemCiphertextLength + nonceLength);
|
|
583
|
+
// Use ML-KEM to decapsulate and get the shared secret
|
|
584
|
+
const sharedSecret = ml_kem768.decapsulate(kemCiphertext, secretKey);
|
|
585
|
+
// Use the shared secret to derive the symmetric key
|
|
586
|
+
const encryptionKey = blake3(sharedSecret, { dkLen: 32 });
|
|
587
|
+
// Decrypt the key using ChaCha20-Poly1305 with the derived key
|
|
588
|
+
const cipher = xchacha20poly1305(encryptionKey, nonce);
|
|
589
|
+
return cipher.decrypt(encryptedData);
|
|
590
|
+
}
|
|
591
|
+
serializeGroupHeader(header) {
|
|
592
|
+
// More robust serialization format
|
|
593
|
+
const timestampBytes = new Uint8Array(8);
|
|
594
|
+
new DataView(timestampBytes.buffer).setBigUint64(0, BigInt(header.timestamp), true);
|
|
595
|
+
const messageNumberBytes = new Uint8Array(8);
|
|
596
|
+
new DataView(messageNumberBytes.buffer).setBigUint64(0, BigInt(header.messageNumber), true);
|
|
597
|
+
const senderIdBytes = utf8ToBytes(header.senderId);
|
|
598
|
+
const messageIdBytes = utf8ToBytes(header.messageId);
|
|
599
|
+
// Format: [messageId length][messageId][timestamp][senderId length][senderId][messageNumber]
|
|
600
|
+
const messageIdLength = new Uint8Array(4);
|
|
601
|
+
new DataView(messageIdLength.buffer).setUint32(0, messageIdBytes.length, true);
|
|
602
|
+
const senderIdLength = new Uint8Array(4);
|
|
603
|
+
new DataView(senderIdLength.buffer).setUint32(0, senderIdBytes.length, true);
|
|
604
|
+
return concatBytes(messageIdLength, messageIdBytes, timestampBytes, senderIdLength, senderIdBytes, messageNumberBytes);
|
|
605
|
+
}
|
|
606
|
+
}
|