nostr-double-ratchet 0.0.20 → 0.0.22

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/Invite.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import { generateSecretKey, getPublicKey, nip44, finalizeEvent, VerifiedEvent, UnsignedEvent, verifyEvent, Filter } from "nostr-tools";
2
- import { INVITE_EVENT_KIND, NostrSubscribe, Unsubscribe, MESSAGE_EVENT_KIND, EncryptFunction, DecryptFunction } from "./types";
2
+ import { INVITE_EVENT_KIND, NostrSubscribe, Unsubscribe, EncryptFunction, DecryptFunction, INVITE_RESPONSE_KIND } from "./types";
3
3
  import { getConversationKey } from "nostr-tools/nip44";
4
4
  import { Session } from "./Session.ts";
5
5
  import { hexToBytes, bytesToHex } from "@noble/hashes/utils";
6
6
 
7
+ const TWO_DAYS = 2 * 24 * 60 * 60
8
+
9
+ const now = () => Math.round(Date.now() / 1000)
10
+ const randomNow = () => Math.round(now() - Math.random() * TWO_DAYS)
11
+
7
12
  /**
8
13
  * Invite is a safe way to exchange session keys and initiate secret sessions.
9
14
  *
@@ -205,10 +210,6 @@ export class Invite {
205
210
  const sharedSecret = hexToBytes(this.sharedSecret);
206
211
  const session = Session.init(nostrSubscribe, this.inviterEphemeralPublicKey, inviteeSessionKey, true, sharedSecret, undefined);
207
212
 
208
- // Create a random keypair for the envelope sender
209
- const randomSenderKey = generateSecretKey();
210
- const randomSenderPublicKey = getPublicKey(randomSenderKey);
211
-
212
213
  // should we take only Encrypt / Decrypt functions, not keys, to make it simpler and with less imports here?
213
214
  // common implementation problem: plaintext, pubkey params in different order
214
215
  const encrypt = typeof encryptor === 'function' ?
@@ -219,16 +220,19 @@ export class Invite {
219
220
 
220
221
  const innerEvent = {
221
222
  pubkey: inviteePublicKey,
222
- tags: [['sharedSecret', this.sharedSecret]],
223
223
  content: await nip44.encrypt(dhEncrypted, sharedSecret),
224
224
  created_at: Math.floor(Date.now() / 1000),
225
225
  };
226
226
 
227
+ // Create a random keypair for the envelope sender
228
+ const randomSenderKey = generateSecretKey();
229
+ const randomSenderPublicKey = getPublicKey(randomSenderKey);
230
+
227
231
  const envelope = {
228
- kind: MESSAGE_EVENT_KIND,
232
+ kind: INVITE_RESPONSE_KIND,
229
233
  pubkey: randomSenderPublicKey,
230
234
  content: nip44.encrypt(JSON.stringify(innerEvent), getConversationKey(randomSenderKey, this.inviterEphemeralPublicKey)),
231
- created_at: Math.floor(Date.now() / 1000),
235
+ created_at: randomNow(),
232
236
  tags: [['p', this.inviterEphemeralPublicKey]],
233
237
  };
234
238
 
@@ -241,7 +245,7 @@ export class Invite {
241
245
  }
242
246
 
243
247
  const filter = {
244
- kinds: [MESSAGE_EVENT_KIND],
248
+ kinds: [INVITE_RESPONSE_KIND],
245
249
  '#p': [this.inviterEphemeralPublicKey],
246
250
  };
247
251
 
@@ -256,11 +260,6 @@ export class Invite {
256
260
  const decrypted = await nip44.decrypt(event.content, getConversationKey(this.inviterEphemeralPrivateKey!, event.pubkey));
257
261
  const innerEvent = JSON.parse(decrypted);
258
262
 
259
- if (!innerEvent.tags || !innerEvent.tags.some(([key, value]: [string, string]) => key === 'sharedSecret' && value === this.sharedSecret)) {
260
- console.error("Invalid secret from event", event);
261
- return;
262
- }
263
-
264
263
  const sharedSecret = hexToBytes(this.sharedSecret);
265
264
  const inviteeIdentity = innerEvent.pubkey;
266
265
  this.usedBy.push(inviteeIdentity);
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;
@@ -130,7 +129,7 @@ export class Session {
130
129
  content: encryptedData,
131
130
  kind: MESSAGE_EVENT_KIND,
132
131
  tags: [["header", encryptedHeader]],
133
- created_at: Math.floor(Date.now() / 1000)
132
+ created_at: Math.floor(now / 1000)
134
133
  }, this.state.ourCurrentNostrKey.privateKey);
135
134
 
136
135
  return {event: nostrEvent, innerEvent: rumor as Rumor};
@@ -220,45 +219,47 @@ 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 and unsubscribe as no more skipped messages from this sender
254
- delete this.state.skippedHeaderKeys[nostrSender];
255
- this.nostrUnsubscribe?.();
256
- this.nostrUnsubscribe = undefined;
257
- }
258
-
259
- 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
+ this.nostrUnsubscribe?.();
259
+ this.nostrUnsubscribe = undefined;
260
260
  }
261
- return null;
261
+
262
+ return nip44.decrypt(ciphertext, messageKey);
262
263
  }
263
264
 
264
265
  // 4. NOSTR EVENT HANDLING
@@ -282,9 +283,9 @@ export class Session {
282
283
  // Decryption with nextSecret also failed
283
284
  }
284
285
 
285
- const keys = this.state.skippedHeaderKeys[event.pubkey];
286
- if (keys) {
287
- for (const key of keys) {
286
+ const skippedKeys = this.state.skippedKeys[event.pubkey];
287
+ if (skippedKeys?.headerKeys) {
288
+ for (const key of skippedKeys.headerKeys) {
288
289
  try {
289
290
  const header = JSON.parse(nip44.decrypt(encryptedHeader, key)) as Header;
290
291
  return [header, false, true];
@@ -304,7 +305,7 @@ export class Session {
304
305
  if (this.state.theirNextNostrPublicKey !== header.nextPublicKey) {
305
306
  this.state.theirCurrentNostrPublicKey = this.state.theirNextNostrPublicKey;
306
307
  this.state.theirNextNostrPublicKey = header.nextPublicKey;
307
- this.nostrUnsubscribe?.(); // should we keep this open for a while? maybe as long as we have skipped messages?
308
+ this.nostrUnsubscribe?.();
308
309
  this.nostrUnsubscribe = this.nostrNextUnsubscribe;
309
310
  this.nostrNextUnsubscribe = this.nostrSubscribe(
310
311
  {authors: [this.state.theirNextNostrPublicKey], kinds: [MESSAGE_EVENT_KIND]},
@@ -316,11 +317,6 @@ export class Session {
316
317
  this.skipMessageKeys(header.previousChainLength, e.pubkey);
317
318
  this.ratchetStep(header.nextPublicKey);
318
319
  }
319
- } else {
320
- const key = skippedMessageIndexKey(e.pubkey, header.number);
321
- if (!(key in this.state.skippedMessageKeys)) {
322
- return // maybe we already processed this message
323
- }
324
320
  }
325
321
 
326
322
  const text = this.ratchetDecrypt(header, e.content, e.pubkey);
@@ -345,12 +341,10 @@ export class Session {
345
341
  (e) => this.handleNostrEvent(e)
346
342
  );
347
343
 
348
- const authors = Object.keys(this.state.skippedHeaderKeys);
344
+ const authors = Object.keys(this.state.skippedKeys);
349
345
  if (this.state.theirCurrentNostrPublicKey && !authors.includes(this.state.theirCurrentNostrPublicKey)) {
350
346
  authors.push(this.state.theirCurrentNostrPublicKey)
351
347
  }
352
- // do we want this unsubscribed on rotation or should we keep it open
353
- // in case more skipped messages are found by relays or peers?
354
348
  this.nostrUnsubscribe = this.nostrSubscribe(
355
349
  {authors, kinds: [MESSAGE_EVENT_KIND]},
356
350
  (e) => this.handleNostrEvent(e)
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
  /**
@@ -83,6 +85,8 @@ export const MESSAGE_EVENT_KIND = 30078;
83
85
  */
84
86
  export const INVITE_EVENT_KIND = 30078;
85
87
 
88
+ export const INVITE_RESPONSE_KIND = 1059;
89
+
86
90
  export const CHAT_MESSAGE_KIND = 14;
87
91
 
88
92
  export const MAX_SKIP = 100;
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
  };