nostr-double-ratchet 0.0.37 → 0.0.48

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.
@@ -0,0 +1,405 @@
1
+ import { generateSecretKey, getPublicKey, finalizeEvent } from "nostr-tools"
2
+ import { AppKeys, DeviceEntry } from "./AppKeys"
3
+ import { Invite } from "./Invite"
4
+ import { NostrSubscribe, NostrPublish, APP_KEYS_EVENT_KIND, Unsubscribe } from "./types"
5
+ import { StorageAdapter, InMemoryStorageAdapter } from "./StorageAdapter"
6
+ import { SessionManager } from "./SessionManager"
7
+
8
+ export interface DelegatePayload {
9
+ identityPubkey: string
10
+ }
11
+
12
+ /**
13
+ * Options for AppKeysManager (authority for AppKeys)
14
+ */
15
+ export interface AppKeysManagerOptions {
16
+ nostrPublish: NostrPublish
17
+ storage?: StorageAdapter
18
+ }
19
+
20
+ /**
21
+ * Options for DelegateManager (device identity)
22
+ */
23
+ export interface DelegateManagerOptions {
24
+ nostrSubscribe: NostrSubscribe
25
+ nostrPublish: NostrPublish
26
+ storage?: StorageAdapter
27
+ }
28
+
29
+
30
+ /**
31
+ * AppKeysManager - Authority for AppKeys.
32
+ * Manages local AppKeys and publishes to relays.
33
+ * Does NOT have device identity (no Invite, no SessionManager creation).
34
+ */
35
+ export class AppKeysManager {
36
+ private readonly nostrPublish: NostrPublish
37
+ private readonly storage: StorageAdapter
38
+
39
+ private appKeys: AppKeys | null = null
40
+ private initialized = false
41
+
42
+ private readonly storageVersion = "3"
43
+ private get versionPrefix(): string {
44
+ return `v${this.storageVersion}`
45
+ }
46
+
47
+ constructor(options: AppKeysManagerOptions) {
48
+ this.nostrPublish = options.nostrPublish
49
+ this.storage = options.storage || new InMemoryStorageAdapter()
50
+ }
51
+
52
+ async init(): Promise<void> {
53
+ if (this.initialized) return
54
+ this.initialized = true
55
+
56
+ // Load local only - no auto-subscribe, no auto-publish, no auto-merge
57
+ this.appKeys = await this.loadAppKeys()
58
+ if (!this.appKeys) {
59
+ this.appKeys = new AppKeys()
60
+ }
61
+ }
62
+
63
+ getAppKeys(): AppKeys | null {
64
+ return this.appKeys
65
+ }
66
+
67
+ getOwnDevices(): DeviceEntry[] {
68
+ return this.appKeys?.getAllDevices() || []
69
+ }
70
+
71
+ /**
72
+ * Add a device to the AppKeys.
73
+ * Only adds identity info - the device publishes its own Invite separately.
74
+ * This is a local-only operation - call publish() to publish to relays.
75
+ */
76
+ addDevice(payload: DelegatePayload): void {
77
+ if (!this.appKeys) {
78
+ this.appKeys = new AppKeys()
79
+ }
80
+
81
+ const device: DeviceEntry = {
82
+ identityPubkey: payload.identityPubkey,
83
+ createdAt: Math.floor(Date.now() / 1000),
84
+ }
85
+ this.appKeys.addDevice(device)
86
+ this.saveAppKeys(this.appKeys).catch(() => {})
87
+ }
88
+
89
+ /**
90
+ * Revoke a device from the AppKeys.
91
+ * This is a local-only operation - call publish() to publish to relays.
92
+ */
93
+ revokeDevice(identityPubkey: string): void {
94
+ if (!this.appKeys) return
95
+
96
+ this.appKeys.removeDevice(identityPubkey)
97
+ this.saveAppKeys(this.appKeys).catch(() => {})
98
+ }
99
+
100
+ /**
101
+ * Publish the current AppKeys to relays.
102
+ * This is the only way to publish - addDevice/revokeDevice are local-only.
103
+ */
104
+ async publish(): Promise<void> {
105
+ if (!this.appKeys) {
106
+ this.appKeys = new AppKeys()
107
+ }
108
+
109
+ const event = this.appKeys.getEvent()
110
+ await this.nostrPublish(event)
111
+ }
112
+
113
+ /**
114
+ * Replace the local AppKeys with the given list and save to storage.
115
+ * Used for authority transfer - receive list from another device, then call publish().
116
+ */
117
+ async setAppKeys(list: AppKeys): Promise<void> {
118
+ this.appKeys = list
119
+ await this.saveAppKeys(list)
120
+ }
121
+
122
+ /**
123
+ * Cleanup resources. Currently a no-op but kept for API consistency.
124
+ */
125
+ close(): void {
126
+ // No-op - no subscriptions to clean up
127
+ }
128
+
129
+ private appKeysKey(): string {
130
+ return `${this.versionPrefix}/app-keys-manager/app-keys`
131
+ }
132
+
133
+ private async loadAppKeys(): Promise<AppKeys | null> {
134
+ const data = await this.storage.get<string>(this.appKeysKey())
135
+ if (!data) return null
136
+ try {
137
+ return AppKeys.deserialize(data)
138
+ } catch {
139
+ return null
140
+ }
141
+ }
142
+
143
+ private async saveAppKeys(list: AppKeys): Promise<void> {
144
+ await this.storage.put(this.appKeysKey(), list.serialize())
145
+ }
146
+ }
147
+
148
+ /**
149
+ * DelegateManager - Device identity manager.
150
+ * ALL devices (including main) use this for their device identity.
151
+ * Publishes own Invite events, used for SessionManager DH encryption.
152
+ */
153
+ export class DelegateManager {
154
+ private readonly nostrSubscribe: NostrSubscribe
155
+ private readonly nostrPublish: NostrPublish
156
+ private readonly storage: StorageAdapter
157
+
158
+ private devicePublicKey: string = ""
159
+ private devicePrivateKey: Uint8Array = new Uint8Array()
160
+
161
+ private invite: Invite | null = null
162
+ private ownerPubkeyFromActivation?: string
163
+ private initialized = false
164
+ private subscriptions: Unsubscribe[] = []
165
+
166
+ private readonly storageVersion = "1"
167
+ private get versionPrefix(): string {
168
+ return `v${this.storageVersion}`
169
+ }
170
+
171
+ constructor(options: DelegateManagerOptions) {
172
+ this.nostrSubscribe = options.nostrSubscribe
173
+ this.nostrPublish = options.nostrPublish
174
+ this.storage = options.storage || new InMemoryStorageAdapter()
175
+ }
176
+
177
+ async init(): Promise<void> {
178
+ if (this.initialized) return
179
+ this.initialized = true
180
+
181
+ // Load or generate identity keys
182
+ const storedPublicKey = await this.storage.get<string>(this.identityPublicKeyKey())
183
+ const storedPrivateKey = await this.storage.get<number[]>(this.identityPrivateKeyKey())
184
+
185
+ if (storedPublicKey && storedPrivateKey) {
186
+ this.devicePublicKey = storedPublicKey
187
+ this.devicePrivateKey = new Uint8Array(storedPrivateKey)
188
+ } else {
189
+ this.devicePrivateKey = generateSecretKey()
190
+ this.devicePublicKey = getPublicKey(this.devicePrivateKey)
191
+ await this.storage.put(this.identityPublicKeyKey(), this.devicePublicKey)
192
+ await this.storage.put(this.identityPrivateKeyKey(), Array.from(this.devicePrivateKey))
193
+ }
194
+
195
+ const storedOwnerPubkey = await this.storage.get<string>(this.ownerPubkeyKey())
196
+ if (storedOwnerPubkey) {
197
+ this.ownerPubkeyFromActivation = storedOwnerPubkey
198
+ }
199
+
200
+ // Load or create Invite for this device
201
+ const savedInvite = await this.loadInvite()
202
+ this.invite = savedInvite || Invite.createNew(this.devicePublicKey, this.devicePublicKey)
203
+ await this.saveInvite(this.invite)
204
+
205
+ // Sign and publish Invite event with this device's identity key
206
+ const inviteEvent = this.invite.getEvent()
207
+ const signedInvite = finalizeEvent(inviteEvent, this.devicePrivateKey)
208
+ await this.nostrPublish(signedInvite).catch(() => {
209
+ // Failed to publish Invite
210
+ })
211
+ }
212
+
213
+ /**
214
+ * Get the registration payload for adding this device to an AppKeysManager.
215
+ * Must be called after init().
216
+ */
217
+ getRegistrationPayload(): DelegatePayload {
218
+ return { identityPubkey: this.devicePublicKey }
219
+ }
220
+
221
+ getIdentityPublicKey(): string {
222
+ return this.devicePublicKey
223
+ }
224
+
225
+ getIdentityKey(): Uint8Array {
226
+ return this.devicePrivateKey
227
+ }
228
+
229
+ getInvite(): Invite | null {
230
+ return this.invite
231
+ }
232
+
233
+ getOwnerPublicKey(): string | null {
234
+ return this.ownerPubkeyFromActivation || null
235
+ }
236
+
237
+ /**
238
+ * Rotate this device's invite - generates new ephemeral keys and shared secret.
239
+ */
240
+ async rotateInvite(): Promise<void> {
241
+ await this.init()
242
+
243
+ this.invite = Invite.createNew(this.devicePublicKey, this.devicePublicKey)
244
+ await this.saveInvite(this.invite)
245
+
246
+ const inviteEvent = this.invite.getEvent()
247
+ const signedInvite = finalizeEvent(inviteEvent, this.devicePrivateKey)
248
+ await this.nostrPublish(signedInvite)
249
+ }
250
+
251
+ /**
252
+ * Activate this device with a known owner.
253
+ * Use this when you know the device has been added (e.g., main device adding itself).
254
+ * Skips fetching from relay - just stores the owner pubkey.
255
+ */
256
+ async activate(ownerPublicKey: string): Promise<void> {
257
+ this.ownerPubkeyFromActivation = ownerPublicKey
258
+ await this.storage.put(this.ownerPubkeyKey(), ownerPublicKey)
259
+ }
260
+
261
+ /**
262
+ * Wait for this device to be activated (added to an AppKeys).
263
+ * Returns the owner's public key once activated.
264
+ * For delegate devices that don't know the owner ahead of time.
265
+ */
266
+ async waitForActivation(timeoutMs = 60000): Promise<string> {
267
+ if (this.ownerPubkeyFromActivation) {
268
+ return this.ownerPubkeyFromActivation
269
+ }
270
+
271
+ return new Promise((resolve, reject) => {
272
+ const timeout = setTimeout(() => {
273
+ unsubscribe()
274
+ reject(new Error("Activation timeout"))
275
+ }, timeoutMs)
276
+
277
+ // Subscribe to all AppKeys events and look for our identityPubkey
278
+ const unsubscribe = this.nostrSubscribe(
279
+ {
280
+ kinds: [APP_KEYS_EVENT_KIND],
281
+ "#d": ["double-ratchet/app-keys"],
282
+ },
283
+ async (event) => {
284
+ try {
285
+ const appKeys = AppKeys.fromEvent(event)
286
+ const device = appKeys.getDevice(this.devicePublicKey)
287
+
288
+ // Check that our identity pubkey is in the list
289
+ if (device) {
290
+ clearTimeout(timeout)
291
+ unsubscribe()
292
+ this.ownerPubkeyFromActivation = event.pubkey
293
+ await this.storage.put(this.ownerPubkeyKey(), event.pubkey)
294
+ resolve(event.pubkey)
295
+ }
296
+ } catch {
297
+ // Invalid AppKeys
298
+ }
299
+ }
300
+ )
301
+
302
+ this.subscriptions.push(unsubscribe)
303
+ })
304
+ }
305
+
306
+ /**
307
+ * Check if this device has been revoked from the owner's AppKeys.
308
+ * @param options.timeoutMs - Timeout for each attempt (default 2000ms)
309
+ * @param options.retries - Number of retry attempts (default 2)
310
+ */
311
+ async isRevoked(options: { timeoutMs?: number; retries?: number } = {}): Promise<boolean> {
312
+ const { timeoutMs = 2000, retries = 2 } = options
313
+ const ownerPubkey = this.getOwnerPublicKey()
314
+ if (!ownerPubkey) return false
315
+
316
+ // Retry loop to handle slow relays
317
+ for (let attempt = 0; attempt <= retries; attempt++) {
318
+ const appKeys = await AppKeys.waitFor(ownerPubkey, this.nostrSubscribe, timeoutMs)
319
+ if (appKeys) {
320
+ const device = appKeys.getDevice(this.devicePublicKey)
321
+ // Device is revoked if not in list
322
+ return !device
323
+ }
324
+ }
325
+
326
+ // No AppKeys found after all retries - assume revoked
327
+ return true
328
+ }
329
+
330
+ close(): void {
331
+ for (const unsubscribe of this.subscriptions) {
332
+ unsubscribe()
333
+ }
334
+ this.subscriptions = []
335
+ }
336
+
337
+ /**
338
+ * Create a SessionManager for this device.
339
+ */
340
+ createSessionManager(sessionStorage?: StorageAdapter): SessionManager {
341
+ if (!this.initialized) {
342
+ throw new Error("DelegateManager must be initialized before creating SessionManager")
343
+ }
344
+
345
+ const ownerPublicKey = this.getOwnerPublicKey()
346
+ if (!ownerPublicKey) {
347
+ throw new Error("Owner public key required for SessionManager - device must be activated first")
348
+ }
349
+
350
+ if (!this.invite || !this.invite.inviterEphemeralPrivateKey) {
351
+ throw new Error("Invite with ephemeral keys required for SessionManager")
352
+ }
353
+
354
+ const ephemeralKeypair = {
355
+ publicKey: this.invite.inviterEphemeralPublicKey,
356
+ privateKey: this.invite.inviterEphemeralPrivateKey,
357
+ }
358
+ const sharedSecret = this.invite.sharedSecret
359
+
360
+ return new SessionManager(
361
+ this.devicePublicKey,
362
+ this.devicePrivateKey,
363
+ this.devicePublicKey, // Use identityPubkey as deviceId
364
+ this.nostrSubscribe,
365
+ this.nostrPublish,
366
+ ownerPublicKey,
367
+ { ephemeralKeypair, sharedSecret },
368
+ sessionStorage || this.storage,
369
+ )
370
+ }
371
+
372
+ private ownerPubkeyKey(): string {
373
+ return `${this.versionPrefix}/device-manager/owner-pubkey`
374
+ }
375
+
376
+ private inviteKey(): string {
377
+ return `${this.versionPrefix}/device-manager/invite`
378
+ }
379
+
380
+ private async loadInvite(): Promise<Invite | null> {
381
+ const data = await this.storage.get<string>(this.inviteKey())
382
+ if (!data) return null
383
+ try {
384
+ return Invite.deserialize(data)
385
+ } catch {
386
+ return null
387
+ }
388
+ }
389
+
390
+ private async saveInvite(invite: Invite): Promise<void> {
391
+ await this.storage.put(this.inviteKey(), invite.serialize())
392
+ }
393
+
394
+ private identityPublicKeyKey(): string {
395
+ return `${this.versionPrefix}/device-manager/identity-public-key`
396
+ }
397
+
398
+ private identityPrivateKeyKey(): string {
399
+ return `${this.versionPrefix}/device-manager/identity-private-key`
400
+ }
401
+ }
402
+
403
+ // Backwards compatibility aliases
404
+ export { AppKeysManager as ApplicationManager }
405
+ export type { AppKeysManagerOptions as ApplicationManagerOptions }
package/src/Invite.ts CHANGED
@@ -1,13 +1,15 @@
1
- import { generateSecretKey, getPublicKey, nip44, finalizeEvent, VerifiedEvent, UnsignedEvent, verifyEvent, Filter } from "nostr-tools";
1
+ import { finalizeEvent, VerifiedEvent, UnsignedEvent, verifyEvent, Filter } from "nostr-tools";
2
2
  import { INVITE_EVENT_KIND, NostrSubscribe, Unsubscribe, EncryptFunction, DecryptFunction, INVITE_RESPONSE_KIND } from "./types";
3
- import { getConversationKey } from "nostr-tools/nip44";
4
3
  import { Session } from "./Session.ts";
5
- import { hexToBytes, bytesToHex } from "@noble/hashes/utils";
6
-
7
- const TWO_DAYS = 2 * 24 * 60 * 60
4
+ import {
5
+ generateEphemeralKeypair,
6
+ generateSharedSecret,
7
+ encryptInviteResponse,
8
+ decryptInviteResponse,
9
+ createSessionFromAccept,
10
+ } from "./inviteUtils";
8
11
 
9
12
  const now = () => Math.round(Date.now() / 1000)
10
- const randomNow = () => Math.round(now() - Math.random() * TWO_DAYS)
11
13
 
12
14
  /**
13
15
  * Invite is a safe way to exchange session keys and initiate secret sessions.
@@ -36,14 +38,13 @@ export class Invite {
36
38
  if (!inviter) {
37
39
  throw new Error("Inviter public key is required");
38
40
  }
39
- const inviterEphemeralPrivateKey = generateSecretKey();
40
- const inviterEphemeralPublicKey = getPublicKey(inviterEphemeralPrivateKey);
41
- const sharedSecret = bytesToHex(generateSecretKey());
41
+ const ephemeralKeypair = generateEphemeralKeypair();
42
+ const sharedSecret = generateSharedSecret();
42
43
  return new Invite(
43
- inviterEphemeralPublicKey,
44
+ ephemeralKeypair.publicKey,
44
45
  sharedSecret,
45
46
  inviter,
46
- inviterEphemeralPrivateKey,
47
+ ephemeralKeypair.privateKey,
47
48
  deviceId,
48
49
  maxUses
49
50
  );
@@ -197,8 +198,8 @@ export class Invite {
197
198
  }
198
199
 
199
200
  /**
200
- * Creates an "invite tombstone" event that clears the original content and removes the list tag.
201
- * Used when the inviter logs out and wants to make the invite invisible to other devices.
201
+ * Creates a tombstone event that replaces the invite, signaling device revocation.
202
+ * The tombstone has the same d-tag but no keys, making it invalid as an invite.
202
203
  */
203
204
  getDeletionEvent(): UnsignedEvent {
204
205
  if (!this.deviceId) {
@@ -207,12 +208,11 @@ export class Invite {
207
208
  return {
208
209
  kind: INVITE_EVENT_KIND,
209
210
  pubkey: this.inviter,
210
- content: "", // deliberately empty
211
+ content: "",
211
212
  created_at: Math.floor(Date.now() / 1000),
212
213
  tags: [
213
- ['ephemeralKey', this.inviterEphemeralPublicKey],
214
- ['sharedSecret', this.sharedSecret],
215
- ['d', 'double-ratchet/invites/' + this.deviceId], // same d tag
214
+ ['d', 'double-ratchet/invites/' + this.deviceId],
215
+ ['l', 'double-ratchet/invites'],
216
216
  ],
217
217
  };
218
218
  }
@@ -221,13 +221,13 @@ export class Invite {
221
221
  * Called by the invitee. Accepts the invite and creates a new session with the inviter.
222
222
  *
223
223
  * @param nostrSubscribe - A function to subscribe to Nostr events
224
- * @param inviteePublicKey - The invitee's public key
224
+ * @param inviteePublicKey - The invitee's identity public key (also serves as device ID)
225
225
  * @param encryptor - The invitee's secret key or a signing/encrypt function
226
- * @param deviceId - Optional device ID to identify the invitee's device
226
+ * @param ownerPublicKey - The invitee's owner/Nostr identity public key (optional for single-device users)
227
227
  * @returns An object containing the new session and an event to be published
228
228
  *
229
229
  * 1. Inner event: No signature, content encrypted with DH(inviter, invitee).
230
- * Purpose: Authenticate invitee. Contains invitee session key and deviceId.
230
+ * Purpose: Authenticate invitee. Contains invitee session key and ownerPublicKey.
231
231
  * 2. Envelope: No signature, content encrypted with DH(inviter, random key).
232
232
  * Purpose: Contains inner event. Hides invitee from others who might have the shared Nostr key.
233
233
 
@@ -238,54 +238,41 @@ export class Invite {
238
238
  nostrSubscribe: NostrSubscribe,
239
239
  inviteePublicKey: string,
240
240
  encryptor: Uint8Array | EncryptFunction,
241
- deviceId?: string,
241
+ ownerPublicKey?: string,
242
242
  ): Promise<{ session: Session, event: VerifiedEvent }> {
243
- const inviteeSessionKey = generateSecretKey();
244
- const inviteeSessionPublicKey = getPublicKey(inviteeSessionKey);
243
+ const inviteeSessionKeypair = generateEphemeralKeypair();
245
244
  const inviterPublicKey = this.inviter || this.inviterEphemeralPublicKey;
246
245
 
247
- const sharedSecret = hexToBytes(this.sharedSecret);
248
- const session = Session.init(nostrSubscribe, this.inviterEphemeralPublicKey, inviteeSessionKey, true, sharedSecret, undefined);
246
+ const session = createSessionFromAccept({
247
+ nostrSubscribe,
248
+ theirPublicKey: this.inviterEphemeralPublicKey,
249
+ ourSessionPrivateKey: inviteeSessionKeypair.privateKey,
250
+ sharedSecret: this.sharedSecret,
251
+ isSender: true,
252
+ });
249
253
 
250
- // should we take only Encrypt / Decrypt functions, not keys, to make it simpler and with less imports here?
251
- // common implementation problem: plaintext, pubkey params in different order
252
- const encrypt = typeof encryptor === 'function' ?
253
- encryptor :
254
- (plaintext: string, pubkey: string) => Promise.resolve(nip44.encrypt(plaintext, getConversationKey(encryptor, pubkey)));
254
+ const encrypt = typeof encryptor === 'function' ? encryptor : undefined;
255
+ const inviteePrivateKey = typeof encryptor === 'function' ? undefined : encryptor;
255
256
 
256
- const payload = JSON.stringify({
257
- sessionKey: inviteeSessionPublicKey,
258
- deviceId: deviceId
257
+ const encrypted = await encryptInviteResponse({
258
+ inviteeSessionPublicKey: inviteeSessionKeypair.publicKey,
259
+ inviteePublicKey,
260
+ inviteePrivateKey,
261
+ inviterPublicKey,
262
+ inviterEphemeralPublicKey: this.inviterEphemeralPublicKey,
263
+ sharedSecret: this.sharedSecret,
264
+ ownerPublicKey,
265
+ encrypt,
259
266
  });
260
- const dhEncrypted = await encrypt(payload, inviterPublicKey);
261
-
262
- const innerEvent = {
263
- pubkey: inviteePublicKey,
264
- content: await nip44.encrypt(dhEncrypted, sharedSecret),
265
- created_at: Math.floor(Date.now() / 1000),
266
- };
267
- const innerJson = JSON.stringify(innerEvent);
268
-
269
- // Create a random keypair for the envelope sender
270
- const randomSenderKey = generateSecretKey();
271
- const randomSenderPublicKey = getPublicKey(randomSenderKey);
272
-
273
- const envelope = {
274
- kind: INVITE_RESPONSE_KIND,
275
- pubkey: randomSenderPublicKey,
276
- content: nip44.encrypt(innerJson, getConversationKey(randomSenderKey, this.inviterEphemeralPublicKey)),
277
- created_at: randomNow(),
278
- tags: [['p', this.inviterEphemeralPublicKey]],
279
- };
280
267
 
281
- return { session, event: finalizeEvent(envelope, randomSenderKey) };
268
+ return { session, event: finalizeEvent(encrypted.envelope, encrypted.randomSenderPrivateKey) };
282
269
  }
283
270
 
284
- listen(decryptor: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (_session: Session, _identity: string, _deviceId?: string) => void): Unsubscribe {
271
+ listen(decryptor: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (_session: Session, _identity: string) => void): Unsubscribe {
285
272
  if (!this.inviterEphemeralPrivateKey) {
286
273
  throw new Error("Inviter session key is not available");
287
274
  }
288
-
275
+
289
276
  const filter = {
290
277
  kinds: [INVITE_RESPONSE_KIND],
291
278
  '#p': [this.inviterEphemeralPublicKey],
@@ -297,40 +284,33 @@ export class Invite {
297
284
  return;
298
285
  }
299
286
 
300
- // Decrypt the outer envelope first
301
- const decrypted = await nip44.decrypt(event.content, getConversationKey(this.inviterEphemeralPrivateKey!, event.pubkey));
302
- const innerEvent = JSON.parse(decrypted);
303
-
304
- const sharedSecret = hexToBytes(this.sharedSecret);
305
- const inviteeIdentity = innerEvent.pubkey;
306
- this.usedBy.push(inviteeIdentity);
307
-
308
- // Decrypt the inner content using shared secret first
309
- const dhEncrypted = await nip44.decrypt(innerEvent.content, sharedSecret);
310
-
311
- // Then decrypt using DH key
312
- const innerDecrypt = typeof decryptor === 'function' ?
313
- decryptor :
314
- (ciphertext: string, pubkey: string) => Promise.resolve(nip44.decrypt(ciphertext, getConversationKey(decryptor, pubkey)));
315
-
316
- const decryptedPayload = await innerDecrypt(dhEncrypted, inviteeIdentity);
317
-
318
- let inviteeSessionPublicKey: string;
319
- let deviceId: string | undefined;
320
-
321
- try {
322
- const parsed = JSON.parse(decryptedPayload);
323
- inviteeSessionPublicKey = parsed.sessionKey;
324
- deviceId = parsed.deviceId;
325
- } catch {
326
- inviteeSessionPublicKey = decryptedPayload;
327
- }
328
-
329
- const name = event.id;
330
- const session = Session.init(nostrSubscribe, inviteeSessionPublicKey, this.inviterEphemeralPrivateKey!, false, sharedSecret, name);
331
-
332
- onSession(session, inviteeIdentity, deviceId);
287
+ const decrypt = typeof decryptor === 'function' ? decryptor : undefined;
288
+ const inviterPrivateKey = typeof decryptor === 'function' ? undefined : decryptor;
289
+
290
+ const decrypted = await decryptInviteResponse({
291
+ envelopeContent: event.content,
292
+ envelopeSenderPubkey: event.pubkey,
293
+ inviterEphemeralPrivateKey: this.inviterEphemeralPrivateKey!,
294
+ inviterPrivateKey,
295
+ sharedSecret: this.sharedSecret,
296
+ decrypt,
297
+ });
298
+
299
+ this.usedBy.push(decrypted.inviteeIdentity);
300
+
301
+ const session = createSessionFromAccept({
302
+ nostrSubscribe,
303
+ theirPublicKey: decrypted.inviteeSessionPublicKey,
304
+ ourSessionPrivateKey: this.inviterEphemeralPrivateKey!,
305
+ sharedSecret: this.sharedSecret,
306
+ isSender: false,
307
+ name: event.id,
308
+ });
309
+
310
+ // inviteeIdentity serves as both identity and device ID
311
+ onSession(session, decrypted.inviteeIdentity);
333
312
  } catch {
313
+ // Failed to process invite response
334
314
  }
335
315
  });
336
316
  }