@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 imma-hq
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,257 @@
1
+ # **Aegis**
2
+
3
+ **Aegis** is a lightweight, storage-agnostic library for client-side End-to-End (E2E) encryption, designed for future security. It combines the NIST-standardized ML-KEM 768 algorithm for quantum-resistant key agreement with high-performance symmetric cryptography (ChaCha20-Poly1305, Blake3) to provide secure 1:1 sessions and scalable group messaging.
4
+
5
+ ---
6
+
7
+ ## **Core Features**
8
+
9
+ - **Post-Quantum Ready**: Uses **ML-KEM 768 (formerly Kyber)** for initial key encapsulation, aligning with NIST standards.
10
+ - **Storage-Agnostic**: You provide a simple key-value storage adapter (e.g., AsyncStorage, LocalStorage, SQLite).
11
+ - **Modern Cryptography**: Symmetric ratchets for forward secrecy and Sender Keys for O(1) group encryption.
12
+ - **Enhanced Security**: Implements proper group key encryption, pre-key signature verification, and secure group membership protocols.
13
+ - **Minimal Dependencies**: Relies on robust, well-audited libraries like `@noble/curves` and `@noble/hashes`.
14
+
15
+ ---
16
+
17
+ ## **Installation**
18
+
19
+ Install the library using npm, yarn, or pnpm.
20
+
21
+ ```bash
22
+ npm install @immahq/aegis
23
+ # or
24
+ yarn add @immahq/aegis
25
+ ```
26
+
27
+ ---
28
+
29
+ ## **Quick Start**
30
+
31
+ ### **1. Implement a Storage Adapter**
32
+
33
+ Aegis requires a minimal async storage adapter to persist keys and session state.
34
+
35
+ ```typescript
36
+ import { StorageAdapter, Identity, Session } from "@immahq/aegis";
37
+
38
+ const myStorage: StorageAdapter = {
39
+ async saveIdentity(identity: Identity) {
40
+ /* Save to secure storage (e.g., JSON.stringify(identity)) */
41
+ },
42
+ async getIdentity(): Promise<Identity | null> {
43
+ /* Retrieve and parse from storage */
44
+ return null;
45
+ },
46
+ async deleteIdentity() {
47
+ /* Delete from storage */
48
+ },
49
+ async saveSession(sessionId: string, session: Session) {
50
+ /* Save session state */
51
+ },
52
+ async getSession(sessionId: string): Promise<Session | null> {
53
+ /* Retrieve session state */
54
+ return null;
55
+ },
56
+ async deleteSession(sessionId: string) {
57
+ /* Delete specific session */
58
+ },
59
+ async listSessions(): Promise<string[]> {
60
+ /* Return list of all session IDs */
61
+ return [];
62
+ },
63
+ async deleteAllSessions() {
64
+ /* Clear all sessions */
65
+ },
66
+ };
67
+
68
+ // Initialize the library before any other operation
69
+ import { E2EE } from "@immahq/aegis";
70
+ const aegis = new E2EE(myStorage);
71
+ ```
72
+
73
+ > **Security Note**: The adapter will store secret key material. In production, always use platform-secured storage (e.g., iOS Keychain, Android Keystore, or a securely encrypted database).
74
+
75
+ ### **2. Create a User Identity**
76
+
77
+ A user identity consists of a post-quantum KEM key pair, a signing key pair, and pre-keys for session establishment.
78
+
79
+ ```typescript
80
+ // This creates and automatically saves the identity to your storage
81
+ const { identity, publicBundle } = await aegis.createIdentity();
82
+ console.log("Your Public Bundle:", publicBundle);
83
+ ```
84
+
85
+ ### **3. Establish a 1:1 Encrypted Session**
86
+
87
+ #### **Initiator (Alice)**
88
+
89
+ ```typescript
90
+ // 1. Fetch recipient's public bundle from your server
91
+ const bobBundle = await getPublicKeyBundle();
92
+
93
+ // 2. Create a session. This performs the ML-KEM key encapsulation.
94
+ const { sessionId, ciphertext, confirmationMac } = await aegis.createSession(
95
+ bobBundle
96
+ );
97
+
98
+ // 3. Send `ciphertext` and `confirmationMac` to Bob via your server
99
+ ```
100
+
101
+ #### **Recipient (Bob)**
102
+
103
+ ```typescript
104
+ // 1. Create the session as a responder using the received ciphertext
105
+ const { sessionId, confirmationMac, isValid } =
106
+ await aegis.createResponderSession(
107
+ aliceBundle,
108
+ receivedCiphertext,
109
+ receivedConfirmationMac
110
+ );
111
+
112
+ if (isValid) {
113
+ console.log("Session established and verified!");
114
+ }
115
+ ```
116
+
117
+ ### **4. Exchange Messages**
118
+
119
+ #### **Encrypt a Message**
120
+
121
+ ```typescript
122
+ const encryptedMessage = await aegis.encryptMessage(
123
+ sessionId,
124
+ "Hello, Bob! This is a secret."
125
+ );
126
+ ```
127
+
128
+ #### **Decrypt a Message**
129
+
130
+ ```typescript
131
+ const plaintext = await aegis.decryptMessage(sessionId, encryptedMessage);
132
+ console.log(plaintext); // "Hello, Bob! This is a secret."
133
+ ```
134
+
135
+ ### **5. Group Messaging with Enhanced Security**
136
+
137
+ Aegis uses the Sender Key protocol for efficient group messaging, where each member encrypts a message once for the entire group. With enhanced security features, group keys are now properly encrypted with member public keys and pre-key signatures are verified.
138
+
139
+ #### **Create a Group with Secure Key Distribution**
140
+
141
+ ```typescript
142
+ import { Aegis, MemoryStorage } from "@immahq/aegis";
143
+
144
+ // Initialize Aegis with your storage adapter
145
+ const aegis = new Aegis(new MemoryStorage());
146
+
147
+ // Prepare member public keys
148
+ const memberKemPublicKeys = new Map<string, Uint8Array>();
149
+ memberKemPublicKeys.set(aliceUserId, aliceKem);
150
+ // ... add other members ...
151
+
152
+ const memberDsaPublicKeys = new Map<string, Uint8Array>();
153
+ memberDsaPublicKeys.set(aliceUserId, aliceDsa);
154
+ // ... add other members ...
155
+
156
+ // Alice creates a group
157
+ const group = await aegis.createGroup(
158
+ "family_chat_2025",
159
+ [aliceUserId, bobUserId, charlieUserId],
160
+ memberKemPublicKeys,
161
+ memberDsaPublicKeys
162
+ );
163
+ ```
164
+
165
+ #### **Broadcast and Decrypt Group Messages**
166
+
167
+ ```typescript
168
+ // Alice encrypts a message for the entire group
169
+ const groupCiphertext = await aegis.encryptGroupMessage(
170
+ "family_chat_2025",
171
+ "Dinner at 8 PM!"
172
+ );
173
+
174
+ // Bob decrypts the group message
175
+ const groupPlaintext = await aegis.decryptGroupMessage(
176
+ "family_chat_2025",
177
+ groupCiphertext
178
+ );
179
+ console.log(new TextDecoder().decode(groupPlaintext)); // "Dinner at 8 PM!"
180
+ ```
181
+
182
+ ---
183
+
184
+ ## **API Reference**
185
+
186
+ ### **Core & Configuration**
187
+
188
+ ### **Identity Management**
189
+
190
+ - **`aegis.createIdentity(): Promise<{ identity, publicBundle }>`**
191
+ - **`aegis.getIdentity(): Promise<Identity>`**
192
+ - **`aegis.getPublicBundle(): Promise<PublicBundle>`**
193
+ - **`aegis.rotateIdentity(): Promise<{ identity, publicBundle }>`**
194
+
195
+ ### **1:1 Sessions**
196
+
197
+ - **`aegis.createSession(peerBundle): Promise<{ sessionId, ciphertext, confirmationMac }>`**
198
+ - **`aegis.createResponderSession(peerBundle, ciphertext, initiatorMac?): Promise<{ sessionId, confirmationMac, isValid }>`**
199
+ - **`aegis.confirmSession(sessionId, responderMac): Promise<boolean>`**
200
+ - **`aegis.encryptMessage(sessionId, plaintext): Promise<EncryptedMessage>`**
201
+ - **`aegis.decryptMessage(sessionId, encryptedMsg): Promise<Uint8Array>`**
202
+ - **`aegis.triggerRatchet(sessionId): Promise<void>`**
203
+
204
+ ### **Group Sessions**
205
+
206
+ - **`aegis.createGroup(name, members, kemKeys, dsaKeys): Promise<Group>`**
207
+ - **`aegis.addGroupMember(groupId, userId, session, userPublicKey): Promise<void>`**
208
+ - **`aegis.removeGroupMember(groupId, userId): Promise<void>`**
209
+ - **`aegis.updateGroupKey(groupId): Promise<void>`**
210
+ - **`aegis.encryptGroupMessage(groupId, message): Promise<GroupMessage>`**
211
+ - **`aegis.decryptGroupMessage(groupId, encryptedMsg): Promise<Uint8Array>`**
212
+ - **`aegis.getGroup(groupId): Promise<Group | null>`**
213
+
214
+ ---
215
+
216
+ ## **Testing and Examples**
217
+
218
+ - **Run Sample Flow**: `npm run sample`
219
+ - This executes `examples/usage.ts`, demonstrating a complete flow: identity creation, session handshake, 1:1 messaging, and group setup.
220
+
221
+ ---
222
+
223
+ ## **Security Model & Best Practices**
224
+
225
+ ### **Cryptographic Foundation**
226
+
227
+ Aegis is built on a hybrid model:
228
+
229
+ 1. **Key Agreement**: **ML-KEM 768 (Kyber)** provides quantum-resistant key encapsulation. This algorithm is now a finalized NIST standard (FIPS 203).
230
+ 2. **Data Encryption**: **ChaCha20-Poly1305** is used for fast, authenticated encryption of message contents.
231
+ 3. **Key Derivation & Hashing**: **Blake3** is used for key derivation and hashing, providing high speed and security.
232
+
233
+ ### **Critical Implementation Notes**
234
+
235
+ ⚠️ **These points are essential for production security:**
236
+
237
+ - **Signed Pre-Key Signatures**: The library verifies pre-key signatures during session initialization. This provides protection against active man-in-the-middle attacks during session establishment.
238
+ - **One-Time Pre-Keys (OTPKs)**: The `getPublicBundle()` function returns a pre-key. Your server should track used keys and ensure they are rotated.
239
+ - **Group Key Encryption**: Group shared keys are properly encrypted using ML-KEM with each member's public key, ensuring that only authorized group members can access the group key.
240
+ - **Storage Security**: The storage adapter you provide holds all secret keys. **You are responsible for its security.** Use platform-backed secure storage.
241
+
242
+ ---
243
+
244
+ ## **Contributing & Roadmap**
245
+
246
+ We welcome contributions. Priority areas include:
247
+
248
+ - Enhanced utilities for server-side OTPK lifecycle management.
249
+ - Improved examples for cross-platform secure storage.
250
+ - Advanced group management features (admin roles, permissions, etc.).
251
+ - Performance optimizations for large group messaging.
252
+
253
+ ---
254
+
255
+ ## **License**
256
+
257
+ MIT
@@ -0,0 +1,42 @@
1
+ import { utf8ToBytes } from "@noble/hashes/utils.js";
2
+ export const CONTEXT_MSG_KEY = utf8ToBytes("msg_key");
3
+ export const CONTEXT_CHAIN_KEY = utf8ToBytes("chain_key");
4
+ export const CONTEXT_CONFIRMATION = utf8ToBytes("key_confirmation");
5
+ export const CONTEXT_CONFIRMATION_RESPONSE = utf8ToBytes("key_confirmation_response");
6
+ // Session states
7
+ export const SESSION_STATES = {
8
+ CREATED: "CREATED",
9
+ KEY_CONFIRMED: "KEY_CONFIRMED",
10
+ ACTIVE: "ACTIVE",
11
+ RATCHET_PENDING: "RATCHET_PENDING",
12
+ ERROR: "ERROR",
13
+ };
14
+ // Error messages
15
+ export const ERRORS = {
16
+ IDENTITY_NOT_FOUND: "Identity not found",
17
+ SESSION_NOT_FOUND: "Session not found",
18
+ INVALID_PREKEY_SIGNATURE: "Invalid prekey signature",
19
+ INVALID_MESSAGE_SIGNATURE: "Invalid message signature",
20
+ REPLAY_ATTACK: "Possible replay attack - message number too low",
21
+ INVALID_PEER_BUNDLE: "Invalid peer bundle",
22
+ MESSAGE_TOO_OLD: "Message is too old to process",
23
+ SESSION_NOT_CONFIRMED: "Session keys not confirmed",
24
+ INVALID_SESSION_STATE: "Invalid session state",
25
+ RATCHET_CIPHERTEXT_MISSING: "KEM ratchet ciphertext missing",
26
+ KEY_CONFIRMATION_FAILED: "Key confirmation failed",
27
+ // Simple replay protection errors
28
+ DUPLICATE_MESSAGE: "Duplicate message detected",
29
+ MESSAGE_TOO_OLD_TIMESTAMP: "Message timestamp is too old",
30
+ };
31
+ // KEM constants
32
+ export const ML_KEM768_PUBLIC_KEY_LENGTH = 1184;
33
+ export const ML_KEM768_SECRET_KEY_LENGTH = 2400;
34
+ export const ML_KEM768_CIPHERTEXT_LENGTH = 1088;
35
+ // Ratchet constants
36
+ export const RATCHET_AFTER_MESSAGES = 50;
37
+ export const MAX_SKIPPED_MESSAGES = 100;
38
+ export const KEY_CONFIRMATION_TIMEOUT = 30000; // 30 seconds
39
+ // Simple replay protection constants
40
+ export const REPLAY_WINDOW_SIZE = 100; // Accept messages up to 100 behind current
41
+ export const MAX_MESSAGE_AGE = 5 * 60 * 1000; // 5 minutes maximum message age
42
+ export const MAX_STORED_MESSAGE_IDS = 1000; // Store last 1000 message IDs
@@ -0,0 +1,306 @@
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 { ERRORS, MAX_MESSAGE_AGE } from "./constants.js";
8
+ import { serializeHeader } from "./utils.js";
9
+ import { KemRatchet } from "./ratchet.js";
10
+ export class CryptoManager {
11
+ constructor(storage) {
12
+ Object.defineProperty(this, "storage", {
13
+ enumerable: true,
14
+ configurable: true,
15
+ writable: true,
16
+ value: void 0
17
+ });
18
+ this.storage = storage;
19
+ }
20
+ async encryptMessage(sessionId, plaintext, identity, shouldRatchet, performSendingRatchet, updateSessionState) {
21
+ try {
22
+ Logger.log("Encrypt", "Encrypting message");
23
+ const session = await this.storage.getSession(sessionId);
24
+ if (!session)
25
+ throw new Error(ERRORS.SESSION_NOT_FOUND);
26
+ if (!session.confirmed && session.state !== "CREATED") {
27
+ throw new Error(ERRORS.SESSION_NOT_CONFIRMED);
28
+ }
29
+ const plaintextBytes = typeof plaintext === "string" ? utf8ToBytes(plaintext) : plaintext;
30
+ const shouldRatchetResult = shouldRatchet(session);
31
+ let kemCiphertext;
32
+ let updatedSession = session;
33
+ if (shouldRatchetResult) {
34
+ Logger.log("Ratchet", "Performing sending KEM ratchet before encryption");
35
+ if (!session.peerRatchetPublicKey) {
36
+ throw new Error("No peer ratchet public key available for ratchet");
37
+ }
38
+ const ratchetResult = performSendingRatchet(session);
39
+ updatedSession = ratchetResult.session;
40
+ kemCiphertext = ratchetResult.kemCiphertext;
41
+ Logger.log("Ratchet", "Prepared sending KEM ratchet", {
42
+ ratchetCount: updatedSession.ratchetCount,
43
+ });
44
+ }
45
+ else if (session.pendingRatchetState) {
46
+ kemCiphertext = session.pendingRatchetState.kemCiphertext;
47
+ updatedSession = session;
48
+ Logger.log("Ratchet", "Using pending ratchet ciphertext", {
49
+ ratchetCount: session.ratchetCount,
50
+ });
51
+ }
52
+ const chainToUse = kemCiphertext
53
+ ? updatedSession.pendingRatchetState?.previousSendingChain ||
54
+ session.sendingChain
55
+ : updatedSession.sendingChain;
56
+ if (!chainToUse) {
57
+ throw new Error("No sending chain available");
58
+ }
59
+ const { messageKey, newChain } = KemRatchet.symmetricRatchet(chainToUse);
60
+ const nonce = randomBytes(24);
61
+ const cipher = xchacha20poly1305(messageKey, nonce);
62
+ const ciphertext = cipher.encrypt(plaintextBytes);
63
+ const fullciphertext = concatBytes(nonce, ciphertext);
64
+ const ratchetPublicKey = updatedSession.currentRatchetKeyPair.publicKey;
65
+ const header = {
66
+ messageId: bytesToHex(blake3(fullciphertext, { dkLen: 32 })),
67
+ ratchetPublicKey: ratchetPublicKey,
68
+ messageNumber: chainToUse.messageNumber,
69
+ previousChainLength: updatedSession.previousSendingChainLength,
70
+ kemCiphertext: kemCiphertext,
71
+ isRatchetMessage: kemCiphertext ? true : false,
72
+ timestamp: Date.now(),
73
+ };
74
+ let confirmationMac;
75
+ if (updatedSession.state === "CREATED" && updatedSession.isInitiator) {
76
+ confirmationMac = updatedSession.confirmationMac;
77
+ Logger.log("Session", "Including key confirmation MAC in first message");
78
+ }
79
+ const headerBytes = serializeHeader(header);
80
+ const messageToSign = concatBytes(headerBytes, fullciphertext);
81
+ const signature = ml_dsa65.sign(messageToSign, identity.dsaKeyPair.secretKey);
82
+ let finalSessionState = updatedSession;
83
+ if (kemCiphertext) {
84
+ if (updatedSession.pendingRatchetState) {
85
+ finalSessionState = {
86
+ ...updatedSession,
87
+ sendingChain: newChain,
88
+ rootKey: updatedSession.pendingRatchetState.newRootKey,
89
+ currentRatchetKeyPair: updatedSession.pendingRatchetState.newRatchetKeyPair,
90
+ state: "ACTIVE",
91
+ };
92
+ }
93
+ }
94
+ else {
95
+ finalSessionState.sendingChain = newChain;
96
+ }
97
+ finalSessionState.lastUsed = Date.now();
98
+ if (finalSessionState.state === "CREATED" &&
99
+ finalSessionState.isInitiator) {
100
+ finalSessionState.state = "KEY_CONFIRMED";
101
+ }
102
+ await updateSessionState(sessionId, finalSessionState);
103
+ Logger.log("Encrypt", "Message encrypted", {
104
+ messageNumber: header.messageNumber,
105
+ ratchetCount: finalSessionState.ratchetCount,
106
+ usedRatchetKey: shouldRatchetResult,
107
+ state: finalSessionState.state,
108
+ isRatchetMessage: !!kemCiphertext,
109
+ });
110
+ return {
111
+ ciphertext: fullciphertext,
112
+ header,
113
+ signature,
114
+ confirmationMac,
115
+ };
116
+ }
117
+ catch (error) {
118
+ Logger.error("Encrypt", "Failed to encrypt message", error);
119
+ throw error;
120
+ }
121
+ }
122
+ async decryptMessage(sessionId, encrypted, needsReceivingRatchet, performReceivingRatchet, getSkippedKeyId, storeReceivedMessageId, cleanupSkippedKeys, applyPendingRatchet, getDecryptionChainForRatchetMessage, updateSessionState) {
123
+ try {
124
+ Logger.log("Decrypt", "Decrypting message");
125
+ const session = await this.storage.getSession(sessionId);
126
+ if (!session)
127
+ throw new Error(ERRORS.SESSION_NOT_FOUND);
128
+ const headerBytes = serializeHeader(encrypted.header);
129
+ const messageToVerify = concatBytes(headerBytes, encrypted.ciphertext);
130
+ const isValid = ml_dsa65.verify(encrypted.signature, messageToVerify, session.peerDsaPublicKey);
131
+ if (!isValid) {
132
+ throw new Error(ERRORS.INVALID_MESSAGE_SIGNATURE);
133
+ }
134
+ if (session.receivedMessageIds.has(encrypted.header.messageId)) {
135
+ Logger.warn("Replay", "Duplicate message detected", {
136
+ messageId: encrypted.header.messageId.substring(0, 16) + "...",
137
+ });
138
+ throw new Error(ERRORS.DUPLICATE_MESSAGE);
139
+ }
140
+ const now = Date.now();
141
+ const messageAge = now - encrypted.header.timestamp;
142
+ if (messageAge > MAX_MESSAGE_AGE) {
143
+ Logger.warn("Replay", "Message too old", {
144
+ age: `${Math.round(messageAge / 1000)}s`,
145
+ maxAge: `${MAX_MESSAGE_AGE / 1000}s`,
146
+ });
147
+ throw new Error(ERRORS.MESSAGE_TOO_OLD_TIMESTAMP);
148
+ }
149
+ session.lastProcessedTimestamp = now;
150
+ if (encrypted.confirmationMac &&
151
+ !session.isInitiator &&
152
+ session.state === "CREATED") {
153
+ Logger.log("Session", "Processing key confirmation from initiator");
154
+ const isValidConfirmation = KemRatchet.verifyConfirmationMac(sessionId, session.rootKey, session.receivingChain.chainKey, encrypted.confirmationMac, false);
155
+ if (isValidConfirmation) {
156
+ session.confirmed = true;
157
+ session.state = "KEY_CONFIRMED";
158
+ Logger.log("Session", "Key confirmation received and verified");
159
+ }
160
+ else {
161
+ session.state = "ERROR";
162
+ await updateSessionState(sessionId, session);
163
+ throw new Error(ERRORS.KEY_CONFIRMATION_FAILED);
164
+ }
165
+ }
166
+ const needsRatchet = needsReceivingRatchet(session, encrypted.header);
167
+ let updatedSession = session;
168
+ let isRatchetMessage = false;
169
+ if (needsRatchet && encrypted.header.kemCiphertext) {
170
+ Logger.log("Ratchet", "Performing receiving KEM ratchet");
171
+ if (!session.currentRatchetKeyPair?.secretKey) {
172
+ throw new Error("No current ratchet secret key available");
173
+ }
174
+ updatedSession = performReceivingRatchet(session, encrypted.header.kemCiphertext);
175
+ updatedSession.peerRatchetPublicKey = encrypted.header.ratchetPublicKey;
176
+ updatedSession.previousSendingChainLength =
177
+ session.sendingChain?.messageNumber ?? 0;
178
+ isRatchetMessage = true;
179
+ Logger.log("Ratchet", "Received KEM ratchet", {
180
+ ratchetCount: updatedSession.ratchetCount,
181
+ peerKeyHash: bytesToHex(encrypted.header.ratchetPublicKey).substring(0, 16) +
182
+ "...",
183
+ });
184
+ }
185
+ else if (needsRatchet && !encrypted.header.kemCiphertext) {
186
+ throw new Error(ERRORS.RATCHET_CIPHERTEXT_MISSING);
187
+ }
188
+ if (!updatedSession.peerRatchetPublicKey &&
189
+ encrypted.header.ratchetPublicKey) {
190
+ updatedSession.peerRatchetPublicKey = encrypted.header.ratchetPublicKey;
191
+ Logger.log("Session", "Stored peer ratchet public key", {
192
+ keyHash: bytesToHex(encrypted.header.ratchetPublicKey).substring(0, 16) +
193
+ "...",
194
+ });
195
+ }
196
+ const skippedKeyId = getSkippedKeyId(encrypted.header.ratchetPublicKey, encrypted.header.messageNumber);
197
+ const skippedKey = updatedSession.skippedMessageKeys.get(skippedKeyId);
198
+ if (skippedKey) {
199
+ Logger.log("Decrypt", "Using skipped message key", {
200
+ messageNumber: encrypted.header.messageNumber,
201
+ });
202
+ const plaintext = this.decryptWithKey(encrypted.ciphertext, skippedKey.messageKey);
203
+ storeReceivedMessageId(updatedSession, encrypted.header.messageId);
204
+ updatedSession.skippedMessageKeys.delete(skippedKeyId);
205
+ updatedSession.lastUsed = Date.now();
206
+ await updateSessionState(sessionId, updatedSession);
207
+ return { plaintext };
208
+ }
209
+ let chainToUseForDecryption;
210
+ if (isRatchetMessage) {
211
+ chainToUseForDecryption =
212
+ getDecryptionChainForRatchetMessage(updatedSession);
213
+ }
214
+ else {
215
+ // If we have a pending ratchet and this message doesn't seem to fit the current receiving chain,
216
+ // it might be the first message acknowledging our ratchet.
217
+ if (updatedSession.state === "RATCHET_PENDING" &&
218
+ updatedSession.pendingRatchetState &&
219
+ encrypted.header.messageNumber <
220
+ updatedSession.receivingChain.messageNumber) {
221
+ chainToUseForDecryption =
222
+ updatedSession.pendingRatchetState.receivingChain;
223
+ Logger.log("Decrypt", "Message number is lower than current chain, trying pending ratchet chain");
224
+ }
225
+ else {
226
+ chainToUseForDecryption = updatedSession.receivingChain;
227
+ }
228
+ }
229
+ if (chainToUseForDecryption &&
230
+ encrypted.header.messageNumber > chainToUseForDecryption.messageNumber) {
231
+ const skipCount = encrypted.header.messageNumber -
232
+ chainToUseForDecryption.messageNumber;
233
+ if (skipCount > updatedSession.maxSkippedMessages) {
234
+ throw new Error(`Cannot skip ${skipCount} messages, max is ${updatedSession.maxSkippedMessages}`);
235
+ }
236
+ Logger.log("Decrypt", "Skipping message keys", {
237
+ from: chainToUseForDecryption.messageNumber,
238
+ to: encrypted.header.messageNumber,
239
+ count: skipCount,
240
+ });
241
+ const { skippedKeys, newChain } = KemRatchet.skipMessageKeys(chainToUseForDecryption, encrypted.header.messageNumber, updatedSession.maxSkippedMessages);
242
+ for (const [msgNum, msgKey] of skippedKeys) {
243
+ const keyId = getSkippedKeyId(encrypted.header.ratchetPublicKey, msgNum);
244
+ updatedSession.skippedMessageKeys.set(keyId, {
245
+ messageKey: msgKey,
246
+ timestamp: Date.now(),
247
+ });
248
+ }
249
+ chainToUseForDecryption = newChain;
250
+ }
251
+ if (!chainToUseForDecryption) {
252
+ throw new Error("No receiving chain available for decryption");
253
+ }
254
+ const { messageKey, newChain } = KemRatchet.symmetricRatchet(chainToUseForDecryption);
255
+ const plaintext = this.decryptWithKey(encrypted.ciphertext, messageKey);
256
+ if (isRatchetMessage) {
257
+ // Chains are already updated in updatedSession by performReceivingRatchet
258
+ }
259
+ else {
260
+ updatedSession.receivingChain = newChain;
261
+ }
262
+ updatedSession.highestReceivedMessageNumber = Math.max(updatedSession.highestReceivedMessageNumber, encrypted.header.messageNumber);
263
+ updatedSession.lastUsed = Date.now();
264
+ storeReceivedMessageId(updatedSession, encrypted.header.messageId);
265
+ cleanupSkippedKeys(updatedSession);
266
+ const needsConfirmation = !updatedSession.isInitiator &&
267
+ encrypted.confirmationMac &&
268
+ updatedSession.state === "KEY_CONFIRMED" &&
269
+ !updatedSession.confirmed;
270
+ if (needsConfirmation) {
271
+ updatedSession.confirmed = true;
272
+ Logger.log("Session", "Ready to send key confirmation response");
273
+ }
274
+ if (updatedSession.pendingRatchetState) {
275
+ if (isRatchetMessage) {
276
+ updatedSession = applyPendingRatchet(updatedSession);
277
+ }
278
+ else if (chainToUseForDecryption ===
279
+ updatedSession.pendingRatchetState.receivingChain) {
280
+ updatedSession = applyPendingRatchet(updatedSession);
281
+ }
282
+ }
283
+ await updateSessionState(sessionId, updatedSession);
284
+ Logger.log("Decrypt", "Message decrypted successfully", {
285
+ messageNumber: encrypted.header.messageNumber,
286
+ ratchetCount: updatedSession.ratchetCount,
287
+ state: updatedSession.state,
288
+ isRatchetMessage,
289
+ });
290
+ return {
291
+ plaintext,
292
+ needsConfirmation: needsConfirmation,
293
+ };
294
+ }
295
+ catch (error) {
296
+ Logger.error("Decrypt", "Failed to decrypt message", error);
297
+ throw error;
298
+ }
299
+ }
300
+ decryptWithKey(ciphertext, messageKey) {
301
+ const nonce = ciphertext.slice(0, 24);
302
+ const encryptedData = ciphertext.slice(24);
303
+ const cipher = xchacha20poly1305(messageKey, nonce);
304
+ return cipher.decrypt(encryptedData);
305
+ }
306
+ }