@moltdm/client 1.2.0 → 1.3.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/dist/index.d.mts CHANGED
@@ -36,6 +36,7 @@ interface Message {
36
36
  replyTo?: string;
37
37
  expiresAt?: string;
38
38
  createdAt: string;
39
+ encryptedSenderKeys?: Record<string, string>;
39
40
  }
40
41
  interface DecryptedMessage {
41
42
  id: string;
@@ -122,6 +123,7 @@ declare class MoltDMClient {
122
123
  private relayUrl;
123
124
  private identity;
124
125
  private senderKeys;
126
+ private receivedSenderKeys;
125
127
  constructor(options?: MoltDMClientOptions);
126
128
  get address(): string;
127
129
  get moltbotId(): string;
@@ -130,6 +132,20 @@ declare class MoltDMClient {
130
132
  private createIdentity;
131
133
  private loadSenderKeys;
132
134
  private saveSenderKeys;
135
+ /**
136
+ * Derive message key from chain key using HMAC
137
+ * message_key = HMAC-SHA256(chain_key, 0x01)
138
+ */
139
+ private deriveMessageKey;
140
+ /**
141
+ * Ratchet chain key forward
142
+ * new_chain_key = HMAC-SHA256(chain_key, 0x02)
143
+ */
144
+ private ratchetChainKey;
145
+ /**
146
+ * Ratchet a chain key forward N steps (for catching up on missed messages)
147
+ */
148
+ private ratchetChainKeyN;
133
149
  startConversation(memberIds: string[], options?: {
134
150
  name?: string;
135
151
  type?: 'dm' | 'group';
@@ -153,6 +169,22 @@ declare class MoltDMClient {
153
169
  }): Promise<{
154
170
  messageId: string;
155
171
  }>;
172
+ /**
173
+ * Rotate sender key for a conversation (call when membership changes)
174
+ */
175
+ rotateSenderKey(conversationId: string): Promise<void>;
176
+ /**
177
+ * Encrypt chain key for each conversation member using X25519 ECDH
178
+ */
179
+ private encryptChainKeyForRecipients;
180
+ /**
181
+ * Decrypt a received chain key using our X25519 private key
182
+ */
183
+ private decryptChainKey;
184
+ /**
185
+ * Decrypt a message using Sender Keys protocol
186
+ */
187
+ decryptMessage(message: Message): Promise<string | null>;
156
188
  getMessages(conversationId: string, options?: {
157
189
  since?: string;
158
190
  limit?: number;
package/dist/index.d.ts CHANGED
@@ -36,6 +36,7 @@ interface Message {
36
36
  replyTo?: string;
37
37
  expiresAt?: string;
38
38
  createdAt: string;
39
+ encryptedSenderKeys?: Record<string, string>;
39
40
  }
40
41
  interface DecryptedMessage {
41
42
  id: string;
@@ -122,6 +123,7 @@ declare class MoltDMClient {
122
123
  private relayUrl;
123
124
  private identity;
124
125
  private senderKeys;
126
+ private receivedSenderKeys;
125
127
  constructor(options?: MoltDMClientOptions);
126
128
  get address(): string;
127
129
  get moltbotId(): string;
@@ -130,6 +132,20 @@ declare class MoltDMClient {
130
132
  private createIdentity;
131
133
  private loadSenderKeys;
132
134
  private saveSenderKeys;
135
+ /**
136
+ * Derive message key from chain key using HMAC
137
+ * message_key = HMAC-SHA256(chain_key, 0x01)
138
+ */
139
+ private deriveMessageKey;
140
+ /**
141
+ * Ratchet chain key forward
142
+ * new_chain_key = HMAC-SHA256(chain_key, 0x02)
143
+ */
144
+ private ratchetChainKey;
145
+ /**
146
+ * Ratchet a chain key forward N steps (for catching up on missed messages)
147
+ */
148
+ private ratchetChainKeyN;
133
149
  startConversation(memberIds: string[], options?: {
134
150
  name?: string;
135
151
  type?: 'dm' | 'group';
@@ -153,6 +169,22 @@ declare class MoltDMClient {
153
169
  }): Promise<{
154
170
  messageId: string;
155
171
  }>;
172
+ /**
173
+ * Rotate sender key for a conversation (call when membership changes)
174
+ */
175
+ rotateSenderKey(conversationId: string): Promise<void>;
176
+ /**
177
+ * Encrypt chain key for each conversation member using X25519 ECDH
178
+ */
179
+ private encryptChainKeyForRecipients;
180
+ /**
181
+ * Decrypt a received chain key using our X25519 private key
182
+ */
183
+ private decryptChainKey;
184
+ /**
185
+ * Decrypt a message using Sender Keys protocol
186
+ */
187
+ decryptMessage(message: Message): Promise<string | null>;
156
188
  getMessages(conversationId: string, options?: {
157
189
  since?: string;
158
190
  limit?: number;
package/dist/index.js CHANGED
@@ -39,6 +39,7 @@ var import_ed25519 = require("@noble/curves/ed25519");
39
39
  var fs = __toESM(require("fs"));
40
40
  var path = __toESM(require("path"));
41
41
  var os = __toESM(require("os"));
42
+ var import_crypto = require("crypto");
42
43
  function toBase64(bytes) {
43
44
  return Buffer.from(bytes).toString("base64");
44
45
  }
@@ -49,7 +50,10 @@ var MoltDMClient = class {
49
50
  storagePath;
50
51
  relayUrl;
51
52
  identity = null;
53
+ // Our sender keys (for messages we send)
52
54
  senderKeys = /* @__PURE__ */ new Map();
55
+ // Received sender keys (for messages from others) - keyed by `${convId}:${fromId}`
56
+ receivedSenderKeys = /* @__PURE__ */ new Map();
53
57
  constructor(options = {}) {
54
58
  const defaultStoragePath = process.env.OPENCLAW_STATE_DIR ? path.join(process.env.OPENCLAW_STATE_DIR, ".moltdm") : path.join(os.homedir(), ".moltdm");
55
59
  this.storagePath = options.storagePath || defaultStoragePath;
@@ -151,10 +155,25 @@ var MoltDMClient = class {
151
155
  const keys = JSON.parse(data);
152
156
  for (const [convId, keyData] of Object.entries(keys)) {
153
157
  const k = keyData;
158
+ const chainKey = fromBase64(k.chainKey || k.key || "");
154
159
  this.senderKeys.set(convId, {
155
- key: fromBase64(k.key),
160
+ chainKey,
161
+ initialChainKey: k.initialChainKey ? fromBase64(k.initialChainKey) : chainKey,
156
162
  version: k.version,
157
- index: k.index
163
+ messageIndex: k.messageIndex ?? k.index ?? 0
164
+ });
165
+ }
166
+ }
167
+ const receivedPath = path.join(this.storagePath, "received_sender_keys.json");
168
+ if (fs.existsSync(receivedPath)) {
169
+ const data = fs.readFileSync(receivedPath, "utf-8");
170
+ const keys = JSON.parse(data);
171
+ for (const [key, keyData] of Object.entries(keys)) {
172
+ const k = keyData;
173
+ this.receivedSenderKeys.set(key, {
174
+ chainKey: fromBase64(k.chainKey),
175
+ version: k.version,
176
+ messageIndex: k.messageIndex
158
177
  });
159
178
  }
160
179
  }
@@ -164,12 +183,64 @@ var MoltDMClient = class {
164
183
  const obj = {};
165
184
  for (const [convId, keyData] of this.senderKeys) {
166
185
  obj[convId] = {
167
- key: toBase64(keyData.key),
186
+ chainKey: toBase64(keyData.chainKey),
187
+ initialChainKey: toBase64(keyData.initialChainKey),
168
188
  version: keyData.version,
169
- index: keyData.index
189
+ messageIndex: keyData.messageIndex
170
190
  };
171
191
  }
172
192
  fs.writeFileSync(keysPath, JSON.stringify(obj, null, 2));
193
+ const receivedPath = path.join(this.storagePath, "received_sender_keys.json");
194
+ const receivedObj = {};
195
+ for (const [key, keyData] of this.receivedSenderKeys) {
196
+ receivedObj[key] = {
197
+ chainKey: toBase64(keyData.chainKey),
198
+ version: keyData.version,
199
+ messageIndex: keyData.messageIndex
200
+ };
201
+ }
202
+ fs.writeFileSync(receivedPath, JSON.stringify(receivedObj, null, 2));
203
+ }
204
+ // ============================================
205
+ // Sender Keys Protocol (Signal-style)
206
+ // ============================================
207
+ /**
208
+ * Derive message key from chain key using HMAC
209
+ * message_key = HMAC-SHA256(chain_key, 0x01)
210
+ */
211
+ deriveMessageKey(chainKey) {
212
+ const keyBuffer = Buffer.from(chainKey);
213
+ const hmac = (0, import_crypto.createHmac)("sha256", keyBuffer);
214
+ hmac.update(Buffer.from([1]));
215
+ const digest = hmac.digest();
216
+ const result = new Uint8Array(32);
217
+ result.set(new Uint8Array(digest));
218
+ return result;
219
+ }
220
+ /**
221
+ * Ratchet chain key forward
222
+ * new_chain_key = HMAC-SHA256(chain_key, 0x02)
223
+ */
224
+ ratchetChainKey(chainKey) {
225
+ const keyBuffer = Buffer.from(chainKey);
226
+ const hmac = (0, import_crypto.createHmac)("sha256", keyBuffer);
227
+ hmac.update(Buffer.from([2]));
228
+ const digest = hmac.digest();
229
+ const result = new Uint8Array(32);
230
+ result.set(new Uint8Array(digest));
231
+ return result;
232
+ }
233
+ /**
234
+ * Ratchet a chain key forward N steps (for catching up on missed messages)
235
+ */
236
+ ratchetChainKeyN(chainKey, steps) {
237
+ const messageKeys = [];
238
+ let current = chainKey;
239
+ for (let i = 0; i < steps; i++) {
240
+ messageKeys.push(this.deriveMessageKey(current));
241
+ current = this.ratchetChainKey(current);
242
+ }
243
+ return { chainKey: current, messageKeys };
173
244
  }
174
245
  // ============================================
175
246
  // Conversations
@@ -228,10 +299,15 @@ var MoltDMClient = class {
228
299
  await this.fetch(`/api/conversations/${conversationId}/members/${memberId}`, {
229
300
  method: "DELETE"
230
301
  });
302
+ if (memberId !== this.moltbotId) {
303
+ await this.rotateSenderKey(conversationId);
304
+ }
231
305
  }
232
306
  async leaveConversation(conversationId) {
233
307
  this.ensureInitialized();
234
308
  await this.removeMember(conversationId, this.moltbotId);
309
+ this.senderKeys.delete(conversationId);
310
+ await this.saveSenderKeys();
235
311
  }
236
312
  async promoteAdmin(conversationId, memberId) {
237
313
  this.ensureInitialized();
@@ -255,30 +331,196 @@ var MoltDMClient = class {
255
331
  // ============================================
256
332
  async send(conversationId, content, options) {
257
333
  this.ensureInitialized();
258
- let senderKey = this.senderKeys.get(conversationId);
259
- if (!senderKey) {
260
- senderKey = {
261
- key: crypto.getRandomValues(new Uint8Array(32)),
334
+ let senderKeyState = this.senderKeys.get(conversationId);
335
+ const isNewKey = !senderKeyState;
336
+ if (!senderKeyState) {
337
+ const initialKey = crypto.getRandomValues(new Uint8Array(32));
338
+ senderKeyState = {
339
+ chainKey: initialKey,
340
+ initialChainKey: new Uint8Array(initialKey),
341
+ // Copy for distribution
262
342
  version: 1,
263
- index: 0
343
+ messageIndex: 0
264
344
  };
265
- this.senderKeys.set(conversationId, senderKey);
266
- await this.saveSenderKeys();
345
+ this.senderKeys.set(conversationId, senderKeyState);
267
346
  }
268
- const ciphertext = await this.encrypt(content, senderKey.key);
347
+ const messageKey = this.deriveMessageKey(senderKeyState.chainKey);
348
+ const currentIndex = senderKeyState.messageIndex;
349
+ senderKeyState.chainKey = this.ratchetChainKey(senderKeyState.chainKey);
350
+ senderKeyState.messageIndex++;
351
+ const ciphertext = await this.encrypt(content, messageKey);
352
+ const encryptedSenderKeys = await this.encryptChainKeyForRecipients(
353
+ conversationId,
354
+ senderKeyState.initialChainKey
355
+ // Send the original, unratcheted key
356
+ );
269
357
  const response = await this.fetch(`/api/conversations/${conversationId}/messages`, {
270
358
  method: "POST",
271
359
  body: JSON.stringify({
272
360
  ciphertext,
273
- senderKeyVersion: senderKey.version,
274
- messageIndex: senderKey.index++,
275
- replyTo: options?.replyTo
361
+ senderKeyVersion: senderKeyState.version,
362
+ messageIndex: currentIndex,
363
+ replyTo: options?.replyTo,
364
+ encryptedSenderKeys
276
365
  })
277
366
  });
278
367
  await this.saveSenderKeys();
279
368
  const data = await response.json();
280
369
  return { messageId: data.message.id };
281
370
  }
371
+ /**
372
+ * Rotate sender key for a conversation (call when membership changes)
373
+ */
374
+ async rotateSenderKey(conversationId) {
375
+ this.ensureInitialized();
376
+ const existingKey = this.senderKeys.get(conversationId);
377
+ const newVersion = existingKey ? existingKey.version + 1 : 1;
378
+ const initialKey = crypto.getRandomValues(new Uint8Array(32));
379
+ this.senderKeys.set(conversationId, {
380
+ chainKey: initialKey,
381
+ initialChainKey: new Uint8Array(initialKey),
382
+ version: newVersion,
383
+ messageIndex: 0
384
+ });
385
+ await this.saveSenderKeys();
386
+ }
387
+ /**
388
+ * Encrypt chain key for each conversation member using X25519 ECDH
389
+ */
390
+ async encryptChainKeyForRecipients(conversationId, chainKey) {
391
+ const encryptedKeys = {};
392
+ try {
393
+ const conversation = await this.getConversation(conversationId);
394
+ for (const memberId of conversation.members) {
395
+ try {
396
+ const response = await fetch(`${this.relayUrl}/api/identity/${memberId}`);
397
+ if (!response.ok) continue;
398
+ const data = await response.json();
399
+ const recipientPreKey = fromBase64(data.identity.signedPreKey);
400
+ const ephemeralPrivate = import_ed25519.x25519.utils.randomPrivateKey();
401
+ const ephemeralPublic = import_ed25519.x25519.getPublicKey(ephemeralPrivate);
402
+ const sharedSecret = import_ed25519.x25519.getSharedSecret(ephemeralPrivate, recipientPreKey);
403
+ const sharedSecretCopy = new Uint8Array(32);
404
+ sharedSecretCopy.set(new Uint8Array(sharedSecret));
405
+ const chainKeyCopy = new Uint8Array(32);
406
+ chainKeyCopy.set(new Uint8Array(chainKey));
407
+ const keyMaterial = await crypto.subtle.importKey(
408
+ "raw",
409
+ sharedSecretCopy.buffer,
410
+ { name: "HKDF" },
411
+ false,
412
+ ["deriveKey"]
413
+ );
414
+ const aesKey = await crypto.subtle.deriveKey(
415
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(32), info: new TextEncoder().encode("moltdm-sender-key") },
416
+ keyMaterial,
417
+ { name: "AES-GCM", length: 256 },
418
+ false,
419
+ ["encrypt"]
420
+ );
421
+ const iv = crypto.getRandomValues(new Uint8Array(12));
422
+ const encrypted = await crypto.subtle.encrypt(
423
+ { name: "AES-GCM", iv },
424
+ aesKey,
425
+ chainKeyCopy.buffer
426
+ );
427
+ const combined = new Uint8Array(32 + 12 + encrypted.byteLength);
428
+ combined.set(ephemeralPublic);
429
+ combined.set(iv, 32);
430
+ combined.set(new Uint8Array(encrypted), 44);
431
+ encryptedKeys[memberId] = toBase64(combined);
432
+ } catch (e) {
433
+ console.error(`Failed to encrypt chain key for ${memberId}:`, e);
434
+ }
435
+ }
436
+ } catch (e) {
437
+ console.error("Failed to encrypt chain keys:", e);
438
+ }
439
+ return encryptedKeys;
440
+ }
441
+ /**
442
+ * Decrypt a received chain key using our X25519 private key
443
+ */
444
+ async decryptChainKey(encryptedBlob) {
445
+ try {
446
+ const combined = fromBase64(encryptedBlob);
447
+ const ephemeralPublic = combined.slice(0, 32);
448
+ const iv = combined.slice(32, 44);
449
+ const encrypted = combined.slice(44);
450
+ const ourPrivateKey = fromBase64(this.identity.signedPreKey.privateKey);
451
+ const sharedSecret = import_ed25519.x25519.getSharedSecret(ourPrivateKey, ephemeralPublic);
452
+ const keyMaterial = await crypto.subtle.importKey(
453
+ "raw",
454
+ new Uint8Array(sharedSecret).buffer,
455
+ { name: "HKDF" },
456
+ false,
457
+ ["deriveKey"]
458
+ );
459
+ const aesKey = await crypto.subtle.deriveKey(
460
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(32), info: new TextEncoder().encode("moltdm-sender-key") },
461
+ keyMaterial,
462
+ { name: "AES-GCM", length: 256 },
463
+ false,
464
+ ["decrypt"]
465
+ );
466
+ const decrypted = await crypto.subtle.decrypt(
467
+ { name: "AES-GCM", iv },
468
+ aesKey,
469
+ encrypted
470
+ );
471
+ return new Uint8Array(decrypted);
472
+ } catch (e) {
473
+ console.error("Failed to decrypt chain key:", e);
474
+ return null;
475
+ }
476
+ }
477
+ /**
478
+ * Decrypt a message using Sender Keys protocol
479
+ */
480
+ async decryptMessage(message) {
481
+ this.ensureInitialized();
482
+ const { conversationId, fromId, ciphertext, senderKeyVersion, messageIndex, encryptedSenderKeys } = message;
483
+ const keyId = `${conversationId}:${fromId}`;
484
+ let receivedKey = this.receivedSenderKeys.get(keyId);
485
+ if (encryptedSenderKeys && encryptedSenderKeys[this.moltbotId]) {
486
+ if (!receivedKey || receivedKey.version !== senderKeyVersion) {
487
+ const chainKey = await this.decryptChainKey(encryptedSenderKeys[this.moltbotId]);
488
+ if (chainKey) {
489
+ receivedKey = {
490
+ chainKey,
491
+ version: senderKeyVersion,
492
+ messageIndex: 0
493
+ };
494
+ this.receivedSenderKeys.set(keyId, receivedKey);
495
+ await this.saveSenderKeys();
496
+ }
497
+ }
498
+ }
499
+ if (!receivedKey) {
500
+ console.error(`No sender key for ${keyId}`);
501
+ return null;
502
+ }
503
+ if (messageIndex > receivedKey.messageIndex) {
504
+ const steps = messageIndex - receivedKey.messageIndex + 1;
505
+ const { chainKey, messageKeys } = this.ratchetChainKeyN(receivedKey.chainKey, steps);
506
+ const messageKey = messageKeys[messageKeys.length - 1];
507
+ receivedKey.chainKey = chainKey;
508
+ receivedKey.messageIndex = messageIndex + 1;
509
+ this.receivedSenderKeys.set(keyId, receivedKey);
510
+ await this.saveSenderKeys();
511
+ return this.decrypt(ciphertext, messageKey);
512
+ } else if (messageIndex === receivedKey.messageIndex) {
513
+ const messageKey = this.deriveMessageKey(receivedKey.chainKey);
514
+ receivedKey.chainKey = this.ratchetChainKey(receivedKey.chainKey);
515
+ receivedKey.messageIndex++;
516
+ this.receivedSenderKeys.set(keyId, receivedKey);
517
+ await this.saveSenderKeys();
518
+ return this.decrypt(ciphertext, messageKey);
519
+ } else {
520
+ console.error(`Message index ${messageIndex} is in the past (current: ${receivedKey.messageIndex})`);
521
+ return null;
522
+ }
523
+ }
282
524
  async getMessages(conversationId, options) {
283
525
  this.ensureInitialized();
284
526
  const params = new URLSearchParams();
@@ -346,7 +588,8 @@ var MoltDMClient = class {
346
588
  method: "POST",
347
589
  body: JSON.stringify({ expiresIn: options?.expiresIn })
348
590
  });
349
- return response.json();
591
+ const data = await response.json();
592
+ return { token: data.invite.token, url: data.url };
350
593
  }
351
594
  async listInvites(conversationId) {
352
595
  this.ensureInitialized();
@@ -437,9 +680,21 @@ var MoltDMClient = class {
437
680
  }
438
681
  async approvePairing(token) {
439
682
  this.ensureInitialized();
683
+ const senderKeysObj = {};
684
+ for (const [convId, keyData] of this.senderKeys) {
685
+ senderKeysObj[convId] = toBase64(keyData.initialChainKey);
686
+ }
687
+ const encryptionKeys = {
688
+ identityKey: this.identity.publicKey,
689
+ privateKey: this.identity.privateKey,
690
+ // Ed25519 private key for signing
691
+ signedPreKeyPrivate: this.identity.signedPreKey.privateKey,
692
+ // X25519 private key for decrypting sender keys
693
+ senderKeys: senderKeysObj
694
+ };
440
695
  const response = await this.fetch("/api/pair/approve", {
441
696
  method: "POST",
442
- body: JSON.stringify({ token })
697
+ body: JSON.stringify({ token, encryptionKeys })
443
698
  });
444
699
  const data = await response.json();
445
700
  return data.device;
@@ -480,7 +735,8 @@ var MoltDMClient = class {
480
735
  const iv = crypto.getRandomValues(new Uint8Array(12));
481
736
  const encoder = new TextEncoder();
482
737
  const data = encoder.encode(plaintext);
483
- const cryptoKey = await crypto.subtle.importKey("raw", key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength), { name: "AES-GCM" }, false, [
738
+ const keyBuffer = new Uint8Array(key).buffer;
739
+ const cryptoKey = await crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, [
484
740
  "encrypt"
485
741
  ]);
486
742
  const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, cryptoKey, data);
@@ -493,7 +749,8 @@ var MoltDMClient = class {
493
749
  const combined = fromBase64(ciphertext);
494
750
  const iv = combined.slice(0, 12);
495
751
  const encrypted = combined.slice(12);
496
- const cryptoKey = await crypto.subtle.importKey("raw", key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength), { name: "AES-GCM" }, false, [
752
+ const keyBuffer = new Uint8Array(key).buffer;
753
+ const cryptoKey = await crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, [
497
754
  "decrypt"
498
755
  ]);
499
756
  const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, cryptoKey, encrypted);
package/dist/index.mjs CHANGED
@@ -4,6 +4,7 @@ import { x25519 } from "@noble/curves/ed25519";
4
4
  import * as fs from "fs";
5
5
  import * as path from "path";
6
6
  import * as os from "os";
7
+ import { createHmac } from "crypto";
7
8
  function toBase64(bytes) {
8
9
  return Buffer.from(bytes).toString("base64");
9
10
  }
@@ -14,7 +15,10 @@ var MoltDMClient = class {
14
15
  storagePath;
15
16
  relayUrl;
16
17
  identity = null;
18
+ // Our sender keys (for messages we send)
17
19
  senderKeys = /* @__PURE__ */ new Map();
20
+ // Received sender keys (for messages from others) - keyed by `${convId}:${fromId}`
21
+ receivedSenderKeys = /* @__PURE__ */ new Map();
18
22
  constructor(options = {}) {
19
23
  const defaultStoragePath = process.env.OPENCLAW_STATE_DIR ? path.join(process.env.OPENCLAW_STATE_DIR, ".moltdm") : path.join(os.homedir(), ".moltdm");
20
24
  this.storagePath = options.storagePath || defaultStoragePath;
@@ -116,10 +120,25 @@ var MoltDMClient = class {
116
120
  const keys = JSON.parse(data);
117
121
  for (const [convId, keyData] of Object.entries(keys)) {
118
122
  const k = keyData;
123
+ const chainKey = fromBase64(k.chainKey || k.key || "");
119
124
  this.senderKeys.set(convId, {
120
- key: fromBase64(k.key),
125
+ chainKey,
126
+ initialChainKey: k.initialChainKey ? fromBase64(k.initialChainKey) : chainKey,
121
127
  version: k.version,
122
- index: k.index
128
+ messageIndex: k.messageIndex ?? k.index ?? 0
129
+ });
130
+ }
131
+ }
132
+ const receivedPath = path.join(this.storagePath, "received_sender_keys.json");
133
+ if (fs.existsSync(receivedPath)) {
134
+ const data = fs.readFileSync(receivedPath, "utf-8");
135
+ const keys = JSON.parse(data);
136
+ for (const [key, keyData] of Object.entries(keys)) {
137
+ const k = keyData;
138
+ this.receivedSenderKeys.set(key, {
139
+ chainKey: fromBase64(k.chainKey),
140
+ version: k.version,
141
+ messageIndex: k.messageIndex
123
142
  });
124
143
  }
125
144
  }
@@ -129,12 +148,64 @@ var MoltDMClient = class {
129
148
  const obj = {};
130
149
  for (const [convId, keyData] of this.senderKeys) {
131
150
  obj[convId] = {
132
- key: toBase64(keyData.key),
151
+ chainKey: toBase64(keyData.chainKey),
152
+ initialChainKey: toBase64(keyData.initialChainKey),
133
153
  version: keyData.version,
134
- index: keyData.index
154
+ messageIndex: keyData.messageIndex
135
155
  };
136
156
  }
137
157
  fs.writeFileSync(keysPath, JSON.stringify(obj, null, 2));
158
+ const receivedPath = path.join(this.storagePath, "received_sender_keys.json");
159
+ const receivedObj = {};
160
+ for (const [key, keyData] of this.receivedSenderKeys) {
161
+ receivedObj[key] = {
162
+ chainKey: toBase64(keyData.chainKey),
163
+ version: keyData.version,
164
+ messageIndex: keyData.messageIndex
165
+ };
166
+ }
167
+ fs.writeFileSync(receivedPath, JSON.stringify(receivedObj, null, 2));
168
+ }
169
+ // ============================================
170
+ // Sender Keys Protocol (Signal-style)
171
+ // ============================================
172
+ /**
173
+ * Derive message key from chain key using HMAC
174
+ * message_key = HMAC-SHA256(chain_key, 0x01)
175
+ */
176
+ deriveMessageKey(chainKey) {
177
+ const keyBuffer = Buffer.from(chainKey);
178
+ const hmac = createHmac("sha256", keyBuffer);
179
+ hmac.update(Buffer.from([1]));
180
+ const digest = hmac.digest();
181
+ const result = new Uint8Array(32);
182
+ result.set(new Uint8Array(digest));
183
+ return result;
184
+ }
185
+ /**
186
+ * Ratchet chain key forward
187
+ * new_chain_key = HMAC-SHA256(chain_key, 0x02)
188
+ */
189
+ ratchetChainKey(chainKey) {
190
+ const keyBuffer = Buffer.from(chainKey);
191
+ const hmac = createHmac("sha256", keyBuffer);
192
+ hmac.update(Buffer.from([2]));
193
+ const digest = hmac.digest();
194
+ const result = new Uint8Array(32);
195
+ result.set(new Uint8Array(digest));
196
+ return result;
197
+ }
198
+ /**
199
+ * Ratchet a chain key forward N steps (for catching up on missed messages)
200
+ */
201
+ ratchetChainKeyN(chainKey, steps) {
202
+ const messageKeys = [];
203
+ let current = chainKey;
204
+ for (let i = 0; i < steps; i++) {
205
+ messageKeys.push(this.deriveMessageKey(current));
206
+ current = this.ratchetChainKey(current);
207
+ }
208
+ return { chainKey: current, messageKeys };
138
209
  }
139
210
  // ============================================
140
211
  // Conversations
@@ -193,10 +264,15 @@ var MoltDMClient = class {
193
264
  await this.fetch(`/api/conversations/${conversationId}/members/${memberId}`, {
194
265
  method: "DELETE"
195
266
  });
267
+ if (memberId !== this.moltbotId) {
268
+ await this.rotateSenderKey(conversationId);
269
+ }
196
270
  }
197
271
  async leaveConversation(conversationId) {
198
272
  this.ensureInitialized();
199
273
  await this.removeMember(conversationId, this.moltbotId);
274
+ this.senderKeys.delete(conversationId);
275
+ await this.saveSenderKeys();
200
276
  }
201
277
  async promoteAdmin(conversationId, memberId) {
202
278
  this.ensureInitialized();
@@ -220,30 +296,196 @@ var MoltDMClient = class {
220
296
  // ============================================
221
297
  async send(conversationId, content, options) {
222
298
  this.ensureInitialized();
223
- let senderKey = this.senderKeys.get(conversationId);
224
- if (!senderKey) {
225
- senderKey = {
226
- key: crypto.getRandomValues(new Uint8Array(32)),
299
+ let senderKeyState = this.senderKeys.get(conversationId);
300
+ const isNewKey = !senderKeyState;
301
+ if (!senderKeyState) {
302
+ const initialKey = crypto.getRandomValues(new Uint8Array(32));
303
+ senderKeyState = {
304
+ chainKey: initialKey,
305
+ initialChainKey: new Uint8Array(initialKey),
306
+ // Copy for distribution
227
307
  version: 1,
228
- index: 0
308
+ messageIndex: 0
229
309
  };
230
- this.senderKeys.set(conversationId, senderKey);
231
- await this.saveSenderKeys();
310
+ this.senderKeys.set(conversationId, senderKeyState);
232
311
  }
233
- const ciphertext = await this.encrypt(content, senderKey.key);
312
+ const messageKey = this.deriveMessageKey(senderKeyState.chainKey);
313
+ const currentIndex = senderKeyState.messageIndex;
314
+ senderKeyState.chainKey = this.ratchetChainKey(senderKeyState.chainKey);
315
+ senderKeyState.messageIndex++;
316
+ const ciphertext = await this.encrypt(content, messageKey);
317
+ const encryptedSenderKeys = await this.encryptChainKeyForRecipients(
318
+ conversationId,
319
+ senderKeyState.initialChainKey
320
+ // Send the original, unratcheted key
321
+ );
234
322
  const response = await this.fetch(`/api/conversations/${conversationId}/messages`, {
235
323
  method: "POST",
236
324
  body: JSON.stringify({
237
325
  ciphertext,
238
- senderKeyVersion: senderKey.version,
239
- messageIndex: senderKey.index++,
240
- replyTo: options?.replyTo
326
+ senderKeyVersion: senderKeyState.version,
327
+ messageIndex: currentIndex,
328
+ replyTo: options?.replyTo,
329
+ encryptedSenderKeys
241
330
  })
242
331
  });
243
332
  await this.saveSenderKeys();
244
333
  const data = await response.json();
245
334
  return { messageId: data.message.id };
246
335
  }
336
+ /**
337
+ * Rotate sender key for a conversation (call when membership changes)
338
+ */
339
+ async rotateSenderKey(conversationId) {
340
+ this.ensureInitialized();
341
+ const existingKey = this.senderKeys.get(conversationId);
342
+ const newVersion = existingKey ? existingKey.version + 1 : 1;
343
+ const initialKey = crypto.getRandomValues(new Uint8Array(32));
344
+ this.senderKeys.set(conversationId, {
345
+ chainKey: initialKey,
346
+ initialChainKey: new Uint8Array(initialKey),
347
+ version: newVersion,
348
+ messageIndex: 0
349
+ });
350
+ await this.saveSenderKeys();
351
+ }
352
+ /**
353
+ * Encrypt chain key for each conversation member using X25519 ECDH
354
+ */
355
+ async encryptChainKeyForRecipients(conversationId, chainKey) {
356
+ const encryptedKeys = {};
357
+ try {
358
+ const conversation = await this.getConversation(conversationId);
359
+ for (const memberId of conversation.members) {
360
+ try {
361
+ const response = await fetch(`${this.relayUrl}/api/identity/${memberId}`);
362
+ if (!response.ok) continue;
363
+ const data = await response.json();
364
+ const recipientPreKey = fromBase64(data.identity.signedPreKey);
365
+ const ephemeralPrivate = x25519.utils.randomPrivateKey();
366
+ const ephemeralPublic = x25519.getPublicKey(ephemeralPrivate);
367
+ const sharedSecret = x25519.getSharedSecret(ephemeralPrivate, recipientPreKey);
368
+ const sharedSecretCopy = new Uint8Array(32);
369
+ sharedSecretCopy.set(new Uint8Array(sharedSecret));
370
+ const chainKeyCopy = new Uint8Array(32);
371
+ chainKeyCopy.set(new Uint8Array(chainKey));
372
+ const keyMaterial = await crypto.subtle.importKey(
373
+ "raw",
374
+ sharedSecretCopy.buffer,
375
+ { name: "HKDF" },
376
+ false,
377
+ ["deriveKey"]
378
+ );
379
+ const aesKey = await crypto.subtle.deriveKey(
380
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(32), info: new TextEncoder().encode("moltdm-sender-key") },
381
+ keyMaterial,
382
+ { name: "AES-GCM", length: 256 },
383
+ false,
384
+ ["encrypt"]
385
+ );
386
+ const iv = crypto.getRandomValues(new Uint8Array(12));
387
+ const encrypted = await crypto.subtle.encrypt(
388
+ { name: "AES-GCM", iv },
389
+ aesKey,
390
+ chainKeyCopy.buffer
391
+ );
392
+ const combined = new Uint8Array(32 + 12 + encrypted.byteLength);
393
+ combined.set(ephemeralPublic);
394
+ combined.set(iv, 32);
395
+ combined.set(new Uint8Array(encrypted), 44);
396
+ encryptedKeys[memberId] = toBase64(combined);
397
+ } catch (e) {
398
+ console.error(`Failed to encrypt chain key for ${memberId}:`, e);
399
+ }
400
+ }
401
+ } catch (e) {
402
+ console.error("Failed to encrypt chain keys:", e);
403
+ }
404
+ return encryptedKeys;
405
+ }
406
+ /**
407
+ * Decrypt a received chain key using our X25519 private key
408
+ */
409
+ async decryptChainKey(encryptedBlob) {
410
+ try {
411
+ const combined = fromBase64(encryptedBlob);
412
+ const ephemeralPublic = combined.slice(0, 32);
413
+ const iv = combined.slice(32, 44);
414
+ const encrypted = combined.slice(44);
415
+ const ourPrivateKey = fromBase64(this.identity.signedPreKey.privateKey);
416
+ const sharedSecret = x25519.getSharedSecret(ourPrivateKey, ephemeralPublic);
417
+ const keyMaterial = await crypto.subtle.importKey(
418
+ "raw",
419
+ new Uint8Array(sharedSecret).buffer,
420
+ { name: "HKDF" },
421
+ false,
422
+ ["deriveKey"]
423
+ );
424
+ const aesKey = await crypto.subtle.deriveKey(
425
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(32), info: new TextEncoder().encode("moltdm-sender-key") },
426
+ keyMaterial,
427
+ { name: "AES-GCM", length: 256 },
428
+ false,
429
+ ["decrypt"]
430
+ );
431
+ const decrypted = await crypto.subtle.decrypt(
432
+ { name: "AES-GCM", iv },
433
+ aesKey,
434
+ encrypted
435
+ );
436
+ return new Uint8Array(decrypted);
437
+ } catch (e) {
438
+ console.error("Failed to decrypt chain key:", e);
439
+ return null;
440
+ }
441
+ }
442
+ /**
443
+ * Decrypt a message using Sender Keys protocol
444
+ */
445
+ async decryptMessage(message) {
446
+ this.ensureInitialized();
447
+ const { conversationId, fromId, ciphertext, senderKeyVersion, messageIndex, encryptedSenderKeys } = message;
448
+ const keyId = `${conversationId}:${fromId}`;
449
+ let receivedKey = this.receivedSenderKeys.get(keyId);
450
+ if (encryptedSenderKeys && encryptedSenderKeys[this.moltbotId]) {
451
+ if (!receivedKey || receivedKey.version !== senderKeyVersion) {
452
+ const chainKey = await this.decryptChainKey(encryptedSenderKeys[this.moltbotId]);
453
+ if (chainKey) {
454
+ receivedKey = {
455
+ chainKey,
456
+ version: senderKeyVersion,
457
+ messageIndex: 0
458
+ };
459
+ this.receivedSenderKeys.set(keyId, receivedKey);
460
+ await this.saveSenderKeys();
461
+ }
462
+ }
463
+ }
464
+ if (!receivedKey) {
465
+ console.error(`No sender key for ${keyId}`);
466
+ return null;
467
+ }
468
+ if (messageIndex > receivedKey.messageIndex) {
469
+ const steps = messageIndex - receivedKey.messageIndex + 1;
470
+ const { chainKey, messageKeys } = this.ratchetChainKeyN(receivedKey.chainKey, steps);
471
+ const messageKey = messageKeys[messageKeys.length - 1];
472
+ receivedKey.chainKey = chainKey;
473
+ receivedKey.messageIndex = messageIndex + 1;
474
+ this.receivedSenderKeys.set(keyId, receivedKey);
475
+ await this.saveSenderKeys();
476
+ return this.decrypt(ciphertext, messageKey);
477
+ } else if (messageIndex === receivedKey.messageIndex) {
478
+ const messageKey = this.deriveMessageKey(receivedKey.chainKey);
479
+ receivedKey.chainKey = this.ratchetChainKey(receivedKey.chainKey);
480
+ receivedKey.messageIndex++;
481
+ this.receivedSenderKeys.set(keyId, receivedKey);
482
+ await this.saveSenderKeys();
483
+ return this.decrypt(ciphertext, messageKey);
484
+ } else {
485
+ console.error(`Message index ${messageIndex} is in the past (current: ${receivedKey.messageIndex})`);
486
+ return null;
487
+ }
488
+ }
247
489
  async getMessages(conversationId, options) {
248
490
  this.ensureInitialized();
249
491
  const params = new URLSearchParams();
@@ -311,7 +553,8 @@ var MoltDMClient = class {
311
553
  method: "POST",
312
554
  body: JSON.stringify({ expiresIn: options?.expiresIn })
313
555
  });
314
- return response.json();
556
+ const data = await response.json();
557
+ return { token: data.invite.token, url: data.url };
315
558
  }
316
559
  async listInvites(conversationId) {
317
560
  this.ensureInitialized();
@@ -402,9 +645,21 @@ var MoltDMClient = class {
402
645
  }
403
646
  async approvePairing(token) {
404
647
  this.ensureInitialized();
648
+ const senderKeysObj = {};
649
+ for (const [convId, keyData] of this.senderKeys) {
650
+ senderKeysObj[convId] = toBase64(keyData.initialChainKey);
651
+ }
652
+ const encryptionKeys = {
653
+ identityKey: this.identity.publicKey,
654
+ privateKey: this.identity.privateKey,
655
+ // Ed25519 private key for signing
656
+ signedPreKeyPrivate: this.identity.signedPreKey.privateKey,
657
+ // X25519 private key for decrypting sender keys
658
+ senderKeys: senderKeysObj
659
+ };
405
660
  const response = await this.fetch("/api/pair/approve", {
406
661
  method: "POST",
407
- body: JSON.stringify({ token })
662
+ body: JSON.stringify({ token, encryptionKeys })
408
663
  });
409
664
  const data = await response.json();
410
665
  return data.device;
@@ -445,7 +700,8 @@ var MoltDMClient = class {
445
700
  const iv = crypto.getRandomValues(new Uint8Array(12));
446
701
  const encoder = new TextEncoder();
447
702
  const data = encoder.encode(plaintext);
448
- const cryptoKey = await crypto.subtle.importKey("raw", key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength), { name: "AES-GCM" }, false, [
703
+ const keyBuffer = new Uint8Array(key).buffer;
704
+ const cryptoKey = await crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, [
449
705
  "encrypt"
450
706
  ]);
451
707
  const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, cryptoKey, data);
@@ -458,7 +714,8 @@ var MoltDMClient = class {
458
714
  const combined = fromBase64(ciphertext);
459
715
  const iv = combined.slice(0, 12);
460
716
  const encrypted = combined.slice(12);
461
- const cryptoKey = await crypto.subtle.importKey("raw", key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength), { name: "AES-GCM" }, false, [
717
+ const keyBuffer = new Uint8Array(key).buffer;
718
+ const cryptoKey = await crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, [
462
719
  "decrypt"
463
720
  ]);
464
721
  const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, cryptoKey, encrypted);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moltdm/client",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "MoltDM client for moltbots - E2E encrypted messaging",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",