nostr-double-ratchet 0.0.5 → 0.0.7

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/Channel.ts CHANGED
@@ -62,6 +62,7 @@ export class Channel {
62
62
  receivingChainMessageNumber: 0,
63
63
  previousSendingChainMessageCount: 0,
64
64
  skippedMessageKeys: {},
65
+ skippedHeaderKeys: {},
65
66
  };
66
67
  const channel = new Channel(nostrSubscribe, state);
67
68
  if (name) channel.name = name;
@@ -106,6 +107,14 @@ export class Channel {
106
107
  return () => this.internalSubscriptions.delete(id)
107
108
  }
108
109
 
110
+ /**
111
+ * Stop listening to incoming messages
112
+ */
113
+ close() {
114
+ this.nostrUnsubscribe?.();
115
+ this.nostrNextUnsubscribe?.();
116
+ }
117
+
109
118
  // 2. RATCHET FUNCTIONS
110
119
  private ratchetEncrypt(plaintext: string): [Header, string] {
111
120
  const [newSendingChainKey, messageKey] = kdf(this.state.sendingChainKey!, new Uint8Array([1]), 2);
@@ -176,6 +185,18 @@ export class Channel {
176
185
  this.state.receivingChainKey = newReceivingChainKey;
177
186
  const key = skippedMessageIndexKey(nostrSender, this.state.receivingChainMessageNumber);
178
187
  this.state.skippedMessageKeys[key] = messageKey;
188
+
189
+ if (!this.state.skippedHeaderKeys[nostrSender]) {
190
+ const secrets: Uint8Array[] = [];
191
+ if (this.state.ourCurrentNostrKey) {
192
+ const currentSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, nostrSender);
193
+ secrets.push(currentSecret);
194
+ }
195
+ const nextSecret = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, nostrSender);
196
+ secrets.push(nextSecret);
197
+ this.state.skippedHeaderKeys[nostrSender] = secrets;
198
+ }
199
+
179
200
  this.state.receivingChainMessageNumber++;
180
201
  }
181
202
  }
@@ -185,19 +206,29 @@ export class Channel {
185
206
  if (key in this.state.skippedMessageKeys) {
186
207
  const mk = this.state.skippedMessageKeys[key];
187
208
  delete this.state.skippedMessageKeys[key];
209
+
210
+ // Check if we have any remaining skipped messages from this sender
211
+ const hasMoreSkippedMessages = Object.keys(this.state.skippedMessageKeys).some(k => k.startsWith(`${nostrSender}:`));
212
+ if (!hasMoreSkippedMessages) {
213
+ // Clean up header keys and unsubscribe as no more skipped messages from this sender
214
+ delete this.state.skippedHeaderKeys[nostrSender];
215
+ this.nostrUnsubscribe?.();
216
+ this.nostrUnsubscribe = undefined;
217
+ }
218
+
188
219
  return nip44.decrypt(ciphertext, mk);
189
220
  }
190
221
  return null;
191
222
  }
192
223
 
193
224
  // 4. NOSTR EVENT HANDLING
194
- private decryptHeader(event: any): [Header, boolean] {
225
+ private decryptHeader(event: any): [Header, boolean, boolean] {
195
226
  const encryptedHeader = event.tags[0][1];
196
227
  if (this.state.ourCurrentNostrKey) {
197
228
  const currentSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, event.pubkey);
198
229
  try {
199
230
  const header = JSON.parse(nip44.decrypt(encryptedHeader, currentSecret)) as Header;
200
- return [header, false];
231
+ return [header, false, false];
201
232
  } catch (error) {
202
233
  // Decryption with currentSecret failed, try with nextSecret
203
234
  }
@@ -206,30 +237,49 @@ export class Channel {
206
237
  const nextSecret = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, event.pubkey);
207
238
  try {
208
239
  const header = JSON.parse(nip44.decrypt(encryptedHeader, nextSecret)) as Header;
209
- return [header, true];
240
+ return [header, true, false];
210
241
  } catch (error) {
211
242
  // Decryption with nextSecret also failed
212
243
  }
213
244
 
214
- throw new Error("Failed to decrypt header with both current and next secrets");
245
+ const keys = this.state.skippedHeaderKeys[event.pubkey];
246
+ if (keys) {
247
+ for (const key of keys) {
248
+ try {
249
+ const header = JSON.parse(nip44.decrypt(encryptedHeader, key)) as Header;
250
+ return [header, false, true];
251
+ } catch (error) {
252
+ // Decryption failed, try next secret
253
+ }
254
+ }
255
+ }
256
+
257
+ throw new Error("Failed to decrypt header with current and skipped header keys");
215
258
  }
216
259
 
217
260
  private handleNostrEvent(e: any) {
218
- const [header, shouldRatchet] = this.decryptHeader(e);
261
+ const [header, shouldRatchet, isSkipped] = this.decryptHeader(e);
219
262
 
220
- if (this.state.theirNostrPublicKey !== header.nextPublicKey) {
221
- this.state.theirNostrPublicKey = header.nextPublicKey;
222
- this.nostrUnsubscribe?.(); // should we keep this open for a while? maybe as long as we have skipped messages?
223
- this.nostrUnsubscribe = this.nostrNextUnsubscribe;
224
- this.nostrNextUnsubscribe = this.nostrSubscribe(
225
- {authors: [this.state.theirNostrPublicKey], kinds: [EVENT_KIND]},
226
- (e) => this.handleNostrEvent(e)
227
- );
228
- }
229
-
230
- if (shouldRatchet) {
231
- this.skipMessageKeys(header.previousChainLength, e.pubkey);
232
- this.ratchetStep(header.nextPublicKey);
263
+ if (!isSkipped) {
264
+ if (this.state.theirNostrPublicKey !== header.nextPublicKey) {
265
+ this.state.theirNostrPublicKey = header.nextPublicKey;
266
+ this.nostrUnsubscribe?.(); // should we keep this open for a while? maybe as long as we have skipped messages?
267
+ this.nostrUnsubscribe = this.nostrNextUnsubscribe;
268
+ this.nostrNextUnsubscribe = this.nostrSubscribe(
269
+ {authors: [this.state.theirNostrPublicKey], kinds: [EVENT_KIND]},
270
+ (e) => this.handleNostrEvent(e)
271
+ );
272
+ }
273
+
274
+ if (shouldRatchet) {
275
+ this.skipMessageKeys(header.previousChainLength, e.pubkey);
276
+ this.ratchetStep(header.nextPublicKey);
277
+ }
278
+ } else {
279
+ const key = skippedMessageIndexKey(e.pubkey, header.number);
280
+ if (!(key in this.state.skippedMessageKeys)) {
281
+ return // maybe we already processed this message
282
+ }
233
283
  }
234
284
 
235
285
  const data = this.ratchetDecrypt(header, e.content, e.pubkey);
package/src/InviteLink.ts CHANGED
@@ -44,19 +44,26 @@ export class InviteLink {
44
44
 
45
45
  static fromUrl(url: string): InviteLink {
46
46
  const parsedUrl = new URL(url);
47
- const inviter = parsedUrl.pathname.slice(1);
48
- const inviterSessionPublicKey = parsedUrl.searchParams.get('s');
49
- const linkSecret = parsedUrl.hash.slice(1);
50
-
51
- if (!inviter) {
52
- throw new Error("Inviter not found in the URL");
47
+ const rawHash = parsedUrl.hash.slice(1);
48
+ if (!rawHash) {
49
+ throw new Error("No invite data found in the URL hash.");
53
50
  }
54
- if (!inviterSessionPublicKey) {
55
- throw new Error("Session key not found in the URL");
51
+
52
+ const decodedHash = decodeURIComponent(rawHash);
53
+ let data: any;
54
+ try {
55
+ data = JSON.parse(decodedHash);
56
+ } catch (err) {
57
+ throw new Error("Invite data in URL hash is not valid JSON: " + err);
58
+ }
59
+
60
+ const { inviter, sessionKey, linkSecret } = data;
61
+ if (!inviter || !sessionKey || !linkSecret) {
62
+ throw new Error("Missing required fields (inviter, sessionKey, linkSecret) in invite data.");
56
63
  }
57
64
 
58
65
  const decodedInviter = nip19.decode(inviter);
59
- const decodedSessionKey = nip19.decode(inviterSessionPublicKey);
66
+ const decodedSessionKey = nip19.decode(sessionKey);
60
67
 
61
68
  if (typeof decodedInviter.data !== 'string') {
62
69
  throw new Error("Decoded inviter is not a string");
@@ -65,10 +72,11 @@ export class InviteLink {
65
72
  throw new Error("Decoded session key is not a string");
66
73
  }
67
74
 
68
- const inviterHexPub = decodedInviter.data;
69
- const inviterSessionPublicKeyHex = decodedSessionKey.data;
70
-
71
- return new InviteLink(inviterSessionPublicKeyHex, linkSecret, inviterHexPub);
75
+ return new InviteLink(
76
+ decodedSessionKey.data,
77
+ linkSecret,
78
+ decodedInviter.data
79
+ );
72
80
  }
73
81
 
74
82
  static deserialize(json: string): InviteLink {
@@ -97,10 +105,15 @@ export class InviteLink {
97
105
  }
98
106
 
99
107
  getUrl(root = "https://iris.to") {
100
- const url = new URL(`${root}/${nip19.npubEncode(this.inviter)}`)
101
- url.searchParams.set('s', nip19.npubEncode(this.inviterSessionPublicKey))
102
- url.hash = this.linkSecret
103
- return url.toString()
108
+ const data = {
109
+ inviter: nip19.npubEncode(this.inviter),
110
+ sessionKey: nip19.npubEncode(this.inviterSessionPublicKey),
111
+ linkSecret: this.linkSecret
112
+ };
113
+ const url = new URL(root);
114
+ url.hash = encodeURIComponent(JSON.stringify(data));
115
+ console.log('url', url.toString())
116
+ return url.toString();
104
117
  }
105
118
 
106
119
  /**
package/src/types.ts CHANGED
@@ -63,6 +63,9 @@ export interface ChannelState {
63
63
 
64
64
  /** Cache of message keys for handling out-of-order messages */
65
65
  skippedMessageKeys: Record<string, Uint8Array>;
66
+
67
+ /** Cache of header keys for handling out-of-order messages */
68
+ skippedHeaderKeys: Record<string, Uint8Array[]>;
66
69
  }
67
70
 
68
71
  /**
package/src/utils.ts CHANGED
@@ -27,6 +27,12 @@ export function serializeChannelState(state: ChannelState): string {
27
27
  bytesToHex(value),
28
28
  ])
29
29
  ),
30
+ skippedHeaderKeys: Object.fromEntries(
31
+ Object.entries(state.skippedHeaderKeys).map(([key, value]) => [
32
+ key,
33
+ value.map(bytes => bytesToHex(bytes))
34
+ ])
35
+ ),
30
36
  });
31
37
  }
32
38
 
@@ -54,6 +60,12 @@ export function deserializeChannelState(data: string): ChannelState {
54
60
  hexToBytes(value as string),
55
61
  ])
56
62
  ),
63
+ skippedHeaderKeys: Object.fromEntries(
64
+ Object.entries(state.skippedHeaderKeys || {}).map(([key, value]) => [
65
+ key,
66
+ (value as string[]).map(hex => hexToBytes(hex))
67
+ ])
68
+ ),
57
69
  };
58
70
  }
59
71