nostr-double-ratchet 0.0.33 → 0.0.35

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
@@ -220,10 +220,11 @@ export class Invite {
220
220
  * @param nostrSubscribe - A function to subscribe to Nostr events
221
221
  * @param inviteePublicKey - The invitee's public key
222
222
  * @param encryptor - The invitee's secret key or a signing/encrypt function
223
+ * @param deviceId - Optional device ID to identify the invitee's device
223
224
  * @returns An object containing the new session and an event to be published
224
225
  *
225
226
  * 1. Inner event: No signature, content encrypted with DH(inviter, invitee).
226
- * Purpose: Authenticate invitee. Contains invitee session key.
227
+ * Purpose: Authenticate invitee. Contains invitee session key and deviceId.
227
228
  * 2. Envelope: No signature, content encrypted with DH(inviter, random key).
228
229
  * Purpose: Contains inner event. Hides invitee from others who might have the shared Nostr key.
229
230
 
@@ -234,6 +235,7 @@ export class Invite {
234
235
  nostrSubscribe: NostrSubscribe,
235
236
  inviteePublicKey: string,
236
237
  encryptor: Uint8Array | EncryptFunction,
238
+ deviceId?: string,
237
239
  ): Promise<{ session: Session, event: VerifiedEvent }> {
238
240
  const inviteeSessionKey = generateSecretKey();
239
241
  const inviteeSessionPublicKey = getPublicKey(inviteeSessionKey);
@@ -248,7 +250,11 @@ export class Invite {
248
250
  encryptor :
249
251
  (plaintext: string, pubkey: string) => Promise.resolve(nip44.encrypt(plaintext, getConversationKey(encryptor, pubkey)));
250
252
 
251
- const dhEncrypted = await encrypt(inviteeSessionPublicKey, inviterPublicKey);
253
+ const payload = JSON.stringify({
254
+ sessionKey: inviteeSessionPublicKey,
255
+ deviceId: deviceId
256
+ });
257
+ const dhEncrypted = await encrypt(payload, inviterPublicKey);
252
258
 
253
259
  const innerEvent = {
254
260
  pubkey: inviteePublicKey,
@@ -272,7 +278,7 @@ export class Invite {
272
278
  return { session, event: finalizeEvent(envelope, randomSenderKey) };
273
279
  }
274
280
 
275
- listen(decryptor: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (_session: Session, _identity?: string) => void): Unsubscribe {
281
+ listen(decryptor: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (_session: Session, _identity: string, _deviceId?: string) => void): Unsubscribe {
276
282
  if (!this.inviterEphemeralPrivateKey) {
277
283
  throw new Error("Inviter session key is not available");
278
284
  }
@@ -304,12 +310,23 @@ export class Invite {
304
310
  decryptor :
305
311
  (ciphertext: string, pubkey: string) => Promise.resolve(nip44.decrypt(ciphertext, getConversationKey(decryptor, pubkey)));
306
312
 
307
- const inviteeSessionPublicKey = await innerDecrypt(dhEncrypted, inviteeIdentity);
313
+ const decryptedPayload = await innerDecrypt(dhEncrypted, inviteeIdentity);
314
+
315
+ let inviteeSessionPublicKey: string;
316
+ let deviceId: string | undefined;
317
+
318
+ try {
319
+ const parsed = JSON.parse(decryptedPayload);
320
+ inviteeSessionPublicKey = parsed.sessionKey;
321
+ deviceId = parsed.deviceId;
322
+ } catch {
323
+ inviteeSessionPublicKey = decryptedPayload;
324
+ }
308
325
 
309
326
  const name = event.id;
310
327
  const session = Session.init(nostrSubscribe, inviteeSessionPublicKey, this.inviterEphemeralPrivateKey!, false, sharedSecret, name);
311
328
 
312
- onSession(session, inviteeIdentity);
329
+ onSession(session, inviteeIdentity, deviceId);
313
330
  } catch {
314
331
  }
315
332
  });
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 } from "./utils";
13
+ import { kdf, deepCopyState } from "./utils";
14
14
 
15
15
  const MAX_SKIP = 1000;
16
16
 
@@ -319,47 +319,64 @@ export class Session {
319
319
  throw new Error("Failed to decrypt header with current and skipped header keys");
320
320
  }
321
321
 
322
+
322
323
  private handleNostrEvent(e: { tags: string[][]; pubkey: string; content: string }) {
324
+ const snapshot = deepCopyState(this.state);
325
+ let pendingSwitch = false;
326
+
323
327
  try {
324
328
  const [header, shouldRatchet, isSkipped] = this.decryptHeader(e);
329
+ if (!isSkipped && this.state.theirNextNostrPublicKey !== header.nextPublicKey) {
330
+ this.state.theirCurrentNostrPublicKey = this.state.theirNextNostrPublicKey;
331
+ this.state.theirNextNostrPublicKey = header.nextPublicKey;
332
+ pendingSwitch = true;
333
+ }
325
334
 
326
335
  if (!isSkipped) {
327
- if (this.state.theirNextNostrPublicKey !== header.nextPublicKey) {
328
- this.state.theirCurrentNostrPublicKey = this.state.theirNextNostrPublicKey;
329
- this.state.theirNextNostrPublicKey = header.nextPublicKey;
330
- this.nostrUnsubscribe?.();
331
- this.nostrUnsubscribe = this.nostrNextUnsubscribe;
332
- this.nostrNextUnsubscribe = this.nostrSubscribe(
333
- {authors: [this.state.theirNextNostrPublicKey], kinds: [MESSAGE_EVENT_KIND]},
334
- (e) => this.handleNostrEvent(e)
335
- );
336
- }
337
-
338
336
  if (shouldRatchet) {
339
337
  this.skipMessageKeys(header.previousChainLength, e.pubkey);
340
338
  this.ratchetStep();
341
339
  }
342
340
  } else {
343
341
  if (!this.state.skippedKeys[e.pubkey]?.messageKeys[header.number]) {
344
- // Maybe we already processed this message — no error
345
- return
342
+ return;
346
343
  }
347
344
  }
348
345
 
349
346
  const text = this.ratchetDecrypt(header, e.content, e.pubkey);
350
347
  const innerEvent = JSON.parse(text);
348
+
351
349
  if (!validateEvent(innerEvent)) {
350
+ this.state = snapshot;
352
351
  return;
353
352
  }
354
-
355
353
  if (innerEvent.id !== getEventHash(innerEvent)) {
354
+ this.state = snapshot;
356
355
  return;
357
356
  }
358
357
 
359
- this.internalSubscriptions.forEach(callback => callback(innerEvent, e as VerifiedEvent));
358
+ if (pendingSwitch) {
359
+ this.nostrUnsubscribe?.();
360
+ this.nostrUnsubscribe = this.nostrNextUnsubscribe;
361
+ this.nostrNextUnsubscribe = this.nostrSubscribe(
362
+ { authors: [this.state.theirNextNostrPublicKey], kinds: [MESSAGE_EVENT_KIND] },
363
+ (ev) => this.handleNostrEvent(ev)
364
+ );
365
+ }
366
+
367
+ this.internalSubscriptions.forEach(callback => callback(innerEvent, e as VerifiedEvent));
360
368
  } catch (error) {
361
- if (error instanceof Error && error.message.includes("Failed to decrypt header")) {
362
- return;
369
+ this.state = snapshot;
370
+ if (error instanceof Error) {
371
+ if (error.message.includes("Failed to decrypt header")) {
372
+ return;
373
+ }
374
+
375
+ if (error.message === "invalid MAC") {
376
+ // Duplicate or stale ciphertexts can hit decrypt() again after a state restore.
377
+ // nip44 throws "invalid MAC" in that case, but the message has already been handled.
378
+ return;
379
+ }
363
380
  }
364
381
  throw error;
365
382
  }
package/src/utils.ts CHANGED
@@ -115,6 +115,41 @@ export function deserializeSessionState(data: string): SessionState {
115
115
  };
116
116
  }
117
117
 
118
+ export function deepCopyState(s: SessionState): SessionState {
119
+ return {
120
+ rootKey: new Uint8Array(s.rootKey),
121
+ theirCurrentNostrPublicKey: s.theirCurrentNostrPublicKey,
122
+ theirNextNostrPublicKey: s.theirNextNostrPublicKey,
123
+ ourCurrentNostrKey: s.ourCurrentNostrKey
124
+ ? {
125
+ publicKey: s.ourCurrentNostrKey.publicKey,
126
+ privateKey: new Uint8Array(s.ourCurrentNostrKey.privateKey),
127
+ }
128
+ : undefined,
129
+ ourNextNostrKey: {
130
+ publicKey: s.ourNextNostrKey.publicKey,
131
+ privateKey: new Uint8Array(s.ourNextNostrKey.privateKey),
132
+ },
133
+ receivingChainKey: s.receivingChainKey ? new Uint8Array(s.receivingChainKey) : undefined,
134
+ sendingChainKey: s.sendingChainKey ? new Uint8Array(s.sendingChainKey) : undefined,
135
+ sendingChainMessageNumber: s.sendingChainMessageNumber,
136
+ receivingChainMessageNumber: s.receivingChainMessageNumber,
137
+ previousSendingChainMessageCount: s.previousSendingChainMessageCount,
138
+ skippedKeys: Object.fromEntries(
139
+ Object.entries(s.skippedKeys).map(([author, entry]: any) => [
140
+ author,
141
+ {
142
+ headerKeys: entry.headerKeys.map((hk: Uint8Array) => new Uint8Array(hk)),
143
+ messageKeys: Object.fromEntries(
144
+ Object.entries(entry.messageKeys).map(([n, mk]: any) => [n, new Uint8Array(mk)])
145
+ ),
146
+ },
147
+ ])
148
+ ),
149
+ };
150
+ }
151
+
152
+
118
153
  export async function* createEventStream(session: Session): AsyncGenerator<Rumor, void, unknown> {
119
154
  const messageQueue: Rumor[] = [];
120
155
  let resolveNext: ((_value: Rumor) => void) | null = null;