nostr-double-ratchet 0.0.21 → 0.0.23

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/src/Session.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  Rumor,
11
11
  CHAT_MESSAGE_KIND,
12
12
  } from "./types";
13
- import { kdf, skippedMessageIndexKey } from "./utils";
13
+ import { kdf } from "./utils";
14
14
 
15
15
  const MAX_SKIP = 1000;
16
16
 
@@ -66,8 +66,7 @@ export class Session {
66
66
  sendingChainMessageNumber: 0,
67
67
  receivingChainMessageNumber: 0,
68
68
  previousSendingChainMessageCount: 0,
69
- skippedMessageKeys: {},
70
- skippedHeaderKeys: {},
69
+ skippedKeys: {},
71
70
  };
72
71
  const session = new Session(nostrSubscribe, state);
73
72
  if (name) session.name = name;
@@ -220,43 +219,45 @@ export class Session {
220
219
  if (this.state.receivingChainMessageNumber + MAX_SKIP < until) {
221
220
  throw new Error("Too many skipped messages");
222
221
  }
222
+
223
+ if (!this.state.skippedKeys[nostrSender]) {
224
+ this.state.skippedKeys[nostrSender] = {
225
+ headerKeys: [],
226
+ messageKeys: {}
227
+ };
228
+
229
+ // Store header keys
230
+ if (this.state.ourCurrentNostrKey) {
231
+ const currentSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, nostrSender);
232
+ this.state.skippedKeys[nostrSender].headerKeys.push(currentSecret);
233
+ }
234
+ const nextSecret = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, nostrSender);
235
+ this.state.skippedKeys[nostrSender].headerKeys.push(nextSecret);
236
+ }
237
+
223
238
  while (this.state.receivingChainMessageNumber < until) {
224
239
  const [newReceivingChainKey, messageKey] = kdf(this.state.receivingChainKey!, new Uint8Array([1]), 2);
225
240
  this.state.receivingChainKey = newReceivingChainKey;
226
- const key = skippedMessageIndexKey(nostrSender, this.state.receivingChainMessageNumber);
227
- this.state.skippedMessageKeys[key] = messageKey;
228
-
229
- if (!this.state.skippedHeaderKeys[nostrSender]) {
230
- const secrets: Uint8Array[] = [];
231
- if (this.state.ourCurrentNostrKey) {
232
- const currentSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, nostrSender);
233
- secrets.push(currentSecret);
234
- }
235
- const nextSecret = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, nostrSender);
236
- secrets.push(nextSecret);
237
- this.state.skippedHeaderKeys[nostrSender] = secrets;
238
- }
239
-
241
+ this.state.skippedKeys[nostrSender].messageKeys[this.state.receivingChainMessageNumber] = messageKey;
240
242
  this.state.receivingChainMessageNumber++;
241
243
  }
242
244
  }
243
245
 
244
246
  private trySkippedMessageKeys(header: Header, ciphertext: string, nostrSender: string): string | null {
245
- const key = skippedMessageIndexKey(nostrSender, header.number);
246
- if (key in this.state.skippedMessageKeys) {
247
- const mk = this.state.skippedMessageKeys[key];
248
- delete this.state.skippedMessageKeys[key];
249
-
250
- // Check if we have any remaining skipped messages from this sender
251
- const hasMoreSkippedMessages = Object.keys(this.state.skippedMessageKeys).some(k => k.startsWith(`${nostrSender}:`));
252
- if (!hasMoreSkippedMessages) {
253
- // Clean up header keys as no more skipped messages from this sender
254
- delete this.state.skippedHeaderKeys[nostrSender];
255
- }
256
-
257
- return nip44.decrypt(ciphertext, mk);
247
+ const skippedKeys = this.state.skippedKeys[nostrSender];
248
+ if (!skippedKeys) return null;
249
+
250
+ const messageKey = skippedKeys.messageKeys[header.number];
251
+ if (!messageKey) return null;
252
+
253
+ delete skippedKeys.messageKeys[header.number];
254
+
255
+ // Clean up if no more skipped messages
256
+ if (Object.keys(skippedKeys.messageKeys).length === 0) {
257
+ delete this.state.skippedKeys[nostrSender];
258
258
  }
259
- return null;
259
+
260
+ return nip44.decrypt(ciphertext, messageKey);
260
261
  }
261
262
 
262
263
  // 4. NOSTR EVENT HANDLING
@@ -280,9 +281,9 @@ export class Session {
280
281
  // Decryption with nextSecret also failed
281
282
  }
282
283
 
283
- const keys = this.state.skippedHeaderKeys[event.pubkey];
284
- if (keys) {
285
- for (const key of keys) {
284
+ const skippedKeys = this.state.skippedKeys[event.pubkey];
285
+ if (skippedKeys?.headerKeys) {
286
+ for (const key of skippedKeys.headerKeys) {
286
287
  try {
287
288
  const header = JSON.parse(nip44.decrypt(encryptedHeader, key)) as Header;
288
289
  return [header, false, true];
@@ -302,7 +303,7 @@ export class Session {
302
303
  if (this.state.theirNextNostrPublicKey !== header.nextPublicKey) {
303
304
  this.state.theirCurrentNostrPublicKey = this.state.theirNextNostrPublicKey;
304
305
  this.state.theirNextNostrPublicKey = header.nextPublicKey;
305
- this.nostrUnsubscribe?.(); // should we keep this open for a while? maybe as long as we have skipped messages?
306
+ this.nostrUnsubscribe?.();
306
307
  this.nostrUnsubscribe = this.nostrNextUnsubscribe;
307
308
  this.nostrNextUnsubscribe = this.nostrSubscribe(
308
309
  {authors: [this.state.theirNextNostrPublicKey], kinds: [MESSAGE_EVENT_KIND]},
@@ -314,11 +315,6 @@ export class Session {
314
315
  this.skipMessageKeys(header.previousChainLength, e.pubkey);
315
316
  this.ratchetStep(header.nextPublicKey);
316
317
  }
317
- } else {
318
- const key = skippedMessageIndexKey(e.pubkey, header.number);
319
- if (!(key in this.state.skippedMessageKeys)) {
320
- return // maybe we already processed this message
321
- }
322
318
  }
323
319
 
324
320
  const text = this.ratchetDecrypt(header, e.content, e.pubkey);
@@ -343,15 +339,19 @@ export class Session {
343
339
  (e) => this.handleNostrEvent(e)
344
340
  );
345
341
 
346
- const authors = Object.keys(this.state.skippedHeaderKeys);
347
- if (this.state.theirCurrentNostrPublicKey && !authors.includes(this.state.theirCurrentNostrPublicKey)) {
348
- authors.push(this.state.theirCurrentNostrPublicKey)
342
+ if (this.state.theirCurrentNostrPublicKey) {
343
+ this.nostrUnsubscribe = this.nostrSubscribe(
344
+ {authors: [this.state.theirCurrentNostrPublicKey], kinds: [MESSAGE_EVENT_KIND]},
345
+ (e) => this.handleNostrEvent(e)
346
+ );
347
+ }
348
+
349
+ const skippedAuthors = Object.keys(this.state.skippedKeys);
350
+ if (skippedAuthors.length) {
351
+ this.nostrSubscribe(
352
+ {authors: skippedAuthors, kinds: [MESSAGE_EVENT_KIND]},
353
+ (e) => this.handleNostrEvent(e)
354
+ );
349
355
  }
350
- // do we want this unsubscribed on rotation or should we keep it open
351
- // in case more skipped messages are found by relays or peers?
352
- this.nostrUnsubscribe = this.nostrSubscribe(
353
- {authors, kinds: [MESSAGE_EVENT_KIND]},
354
- (e) => this.handleNostrEvent(e)
355
- );
356
356
  }
357
357
  }
package/src/types.ts CHANGED
@@ -48,11 +48,13 @@ export interface SessionState {
48
48
  /** Number of messages sent in previous sending chain */
49
49
  previousSendingChainMessageCount: number;
50
50
 
51
- /** Cache of message keys for handling out-of-order messages */
52
- skippedMessageKeys: Record<string, Uint8Array>;
53
-
54
- /** Cache of header keys for handling out-of-order messages */
55
- skippedHeaderKeys: Record<string, Uint8Array[]>;
51
+ /** Cache of message & header keys for handling out-of-order messages */
52
+ skippedKeys: {
53
+ [pubKey: string]: {
54
+ headerKeys: Uint8Array[],
55
+ messageKeys: {[msgIndex: number]: Uint8Array}
56
+ };
57
+ };
56
58
  }
57
59
 
58
60
  /**
package/src/utils.ts CHANGED
@@ -4,8 +4,11 @@ import { Session } from "./Session.ts";
4
4
  import { extract as hkdf_extract, expand as hkdf_expand } from '@noble/hashes/hkdf';
5
5
  import { sha256 } from '@noble/hashes/sha256';
6
6
 
7
+ const VERSION_NUMBER = 1;
8
+
7
9
  export function serializeSessionState(state: SessionState): string {
8
10
  return JSON.stringify({
11
+ version: VERSION_NUMBER,
9
12
  rootKey: bytesToHex(state.rootKey),
10
13
  theirCurrentNostrPublicKey: state.theirCurrentNostrPublicKey,
11
14
  theirNextNostrPublicKey: state.theirNextNostrPublicKey,
@@ -22,16 +25,18 @@ export function serializeSessionState(state: SessionState): string {
22
25
  sendingChainMessageNumber: state.sendingChainMessageNumber,
23
26
  receivingChainMessageNumber: state.receivingChainMessageNumber,
24
27
  previousSendingChainMessageCount: state.previousSendingChainMessageCount,
25
- skippedMessageKeys: Object.fromEntries(
26
- Object.entries(state.skippedMessageKeys).map(([key, value]) => [
27
- key,
28
- bytesToHex(value),
29
- ])
30
- ),
31
- skippedHeaderKeys: Object.fromEntries(
32
- Object.entries(state.skippedHeaderKeys).map(([key, value]) => [
33
- key,
34
- value.map(bytes => bytesToHex(bytes))
28
+ skippedKeys: Object.fromEntries(
29
+ Object.entries(state.skippedKeys).map(([pubKey, value]) => [
30
+ pubKey,
31
+ {
32
+ headerKeys: value.headerKeys.map(bytes => bytesToHex(bytes)),
33
+ messageKeys: Object.fromEntries(
34
+ Object.entries(value.messageKeys).map(([msgIndex, bytes]) => [
35
+ msgIndex,
36
+ bytesToHex(bytes)
37
+ ])
38
+ )
39
+ }
35
40
  ])
36
41
  ),
37
42
  });
@@ -39,6 +44,43 @@ export function serializeSessionState(state: SessionState): string {
39
44
 
40
45
  export function deserializeSessionState(data: string): SessionState {
41
46
  const state = JSON.parse(data);
47
+
48
+ // Handle version 0 (legacy format)
49
+ if (!state.version) {
50
+ const skippedKeys: SessionState['skippedKeys'] = {};
51
+
52
+ // Migrate old skipped keys format to new structure
53
+ if (state.skippedMessageKeys) {
54
+ Object.entries(state.skippedMessageKeys).forEach(([pubKey, messageKeys]: [string, any]) => {
55
+ skippedKeys[pubKey] = {
56
+ headerKeys: state.skippedHeaderKeys?.[pubKey] || [],
57
+ messageKeys: messageKeys
58
+ };
59
+ });
60
+ }
61
+
62
+ return {
63
+ rootKey: hexToBytes(state.rootKey),
64
+ theirCurrentNostrPublicKey: state.theirCurrentNostrPublicKey,
65
+ theirNextNostrPublicKey: state.theirNextNostrPublicKey,
66
+ ourCurrentNostrKey: state.ourCurrentNostrKey ? {
67
+ publicKey: state.ourCurrentNostrKey.publicKey,
68
+ privateKey: hexToBytes(state.ourCurrentNostrKey.privateKey),
69
+ } : undefined,
70
+ ourNextNostrKey: {
71
+ publicKey: state.ourNextNostrKey.publicKey,
72
+ privateKey: hexToBytes(state.ourNextNostrKey.privateKey),
73
+ },
74
+ receivingChainKey: state.receivingChainKey ? hexToBytes(state.receivingChainKey) : undefined,
75
+ sendingChainKey: state.sendingChainKey ? hexToBytes(state.sendingChainKey) : undefined,
76
+ sendingChainMessageNumber: state.sendingChainMessageNumber,
77
+ receivingChainMessageNumber: state.receivingChainMessageNumber,
78
+ previousSendingChainMessageCount: state.previousSendingChainMessageCount,
79
+ skippedKeys
80
+ };
81
+ }
82
+
83
+ // Handle current version
42
84
  return {
43
85
  rootKey: hexToBytes(state.rootKey),
44
86
  theirCurrentNostrPublicKey: state.theirCurrentNostrPublicKey,
@@ -56,16 +98,18 @@ export function deserializeSessionState(data: string): SessionState {
56
98
  sendingChainMessageNumber: state.sendingChainMessageNumber,
57
99
  receivingChainMessageNumber: state.receivingChainMessageNumber,
58
100
  previousSendingChainMessageCount: state.previousSendingChainMessageCount,
59
- skippedMessageKeys: Object.fromEntries(
60
- Object.entries(state.skippedMessageKeys).map(([key, value]) => [
61
- key,
62
- hexToBytes(value as string),
63
- ])
64
- ),
65
- skippedHeaderKeys: Object.fromEntries(
66
- Object.entries(state.skippedHeaderKeys || {}).map(([key, value]) => [
67
- key,
68
- (value as string[]).map(hex => hexToBytes(hex))
101
+ skippedKeys: Object.fromEntries(
102
+ Object.entries(state.skippedKeys || {}).map(([pubKey, value]) => [
103
+ pubKey,
104
+ {
105
+ headerKeys: (value as any).headerKeys.map((hex: string) => hexToBytes(hex)),
106
+ messageKeys: Object.fromEntries(
107
+ Object.entries((value as any).messageKeys).map(([msgIndex, hex]) => [
108
+ msgIndex,
109
+ hexToBytes(hex as string)
110
+ ])
111
+ )
112
+ }
69
113
  ])
70
114
  ),
71
115
  };