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.
@@ -1,46 +1,60 @@
1
1
  import {
2
- DecryptFunction,
2
+ IdentityKey,
3
3
  NostrSubscribe,
4
4
  NostrPublish,
5
5
  Rumor,
6
6
  Unsubscribe,
7
- INVITE_EVENT_KIND,
7
+ APP_KEYS_EVENT_KIND,
8
8
  CHAT_MESSAGE_KIND,
9
9
  } from "./types"
10
10
  import { StorageAdapter, InMemoryStorageAdapter } from "./StorageAdapter"
11
+ import { AppKeys, DeviceEntry } from "./AppKeys"
11
12
  import { Invite } from "./Invite"
12
13
  import { Session } from "./Session"
13
14
  import { serializeSessionState, deserializeSessionState } from "./utils"
14
- import { getEventHash, VerifiedEvent } from "nostr-tools"
15
+ import { decryptInviteResponse, createSessionFromAccept } from "./inviteUtils"
16
+ import { getEventHash } from "nostr-tools"
15
17
 
16
18
  export type OnEventCallback = (event: Rumor, from: string) => void
17
19
 
18
- interface DeviceRecord {
20
+ /**
21
+ * Credentials for the invite handshake - used to listen for and decrypt invite responses
22
+ */
23
+ export interface InviteCredentials {
24
+ ephemeralKeypair: { publicKey: string; privateKey: Uint8Array }
25
+ sharedSecret: string
26
+ }
27
+
28
+ export interface DeviceRecord {
19
29
  deviceId: string
20
30
  activeSession?: Session
21
31
  inactiveSessions: Session[]
22
32
  createdAt: number
23
- staleAt?: number
24
33
  }
25
34
 
26
- interface UserRecord {
35
+ export interface UserRecord {
27
36
  publicKey: string
28
37
  devices: Map<string, DeviceRecord>
38
+ /** Device identity pubkeys from AppKeys - used to rebuild delegateToOwner on load */
39
+ knownDeviceIdentities: string[]
29
40
  }
30
41
 
31
- type StoredSessionEntry = ReturnType<typeof serializeSessionState>
42
+ interface StoredSessionEntry {
43
+ name: string
44
+ state: string
45
+ }
32
46
 
33
47
  interface StoredDeviceRecord {
34
48
  deviceId: string
35
49
  activeSession: StoredSessionEntry | null
36
50
  inactiveSessions: StoredSessionEntry[]
37
51
  createdAt: number
38
- staleAt?: number
39
52
  }
40
53
 
41
54
  interface StoredUserRecord {
42
55
  publicKey: string
43
56
  devices: StoredDeviceRecord[]
57
+ knownDeviceIdentities?: string[]
44
58
  }
45
59
 
46
60
  export class SessionManager {
@@ -53,20 +67,26 @@ export class SessionManager {
53
67
  private storage: StorageAdapter
54
68
  private nostrSubscribe: NostrSubscribe
55
69
  private nostrPublish: NostrPublish
56
- private ourIdentityKey: Uint8Array | DecryptFunction
70
+ private identityKey: IdentityKey
57
71
  private ourPublicKey: string
72
+ // Owner's public key - used for grouping devices together (all devices are delegates)
73
+ private ownerPublicKey: string
74
+
75
+ // Credentials for invite handshake
76
+ private inviteKeys: InviteCredentials
58
77
 
59
78
  // Data
60
79
  private userRecords: Map<string, UserRecord> = new Map()
61
80
  private messageHistory: Map<string, Rumor[]> = new Map()
62
- private currentDeviceInvite: Invite | null = null
81
+ // Map delegate device pubkeys to their owner's pubkey
82
+ private delegateToOwner: Map<string, string> = new Map()
83
+ // Track processed InviteResponse event IDs to prevent replay
84
+ private processedInviteResponses: Set<string> = new Set()
63
85
 
64
86
  // Subscriptions
65
- private ourDeviceInviteSubscription: Unsubscribe | null = null
66
- private ourDeviceIntiveTombstoneSubscription: Unsubscribe | null = null
87
+ private ourInviteResponseSubscription: Unsubscribe | null = null
67
88
  private inviteSubscriptions: Map<string, Unsubscribe> = new Map()
68
89
  private sessionSubscriptions: Map<string, Unsubscribe> = new Map()
69
- private inviteTombstoneSubscriptions: Map<string, Unsubscribe> = new Map()
70
90
 
71
91
  // Callbacks
72
92
  private internalSubscriptions: Set<OnEventCallback> = new Set()
@@ -76,18 +96,22 @@ export class SessionManager {
76
96
 
77
97
  constructor(
78
98
  ourPublicKey: string,
79
- ourIdentityKey: Uint8Array | DecryptFunction,
99
+ identityKey: IdentityKey,
80
100
  deviceId: string,
81
101
  nostrSubscribe: NostrSubscribe,
82
102
  nostrPublish: NostrPublish,
83
- storage?: StorageAdapter
103
+ ownerPublicKey: string,
104
+ inviteKeys: InviteCredentials,
105
+ storage?: StorageAdapter,
84
106
  ) {
85
107
  this.userRecords = new Map()
86
108
  this.nostrSubscribe = nostrSubscribe
87
109
  this.nostrPublish = nostrPublish
88
110
  this.ourPublicKey = ourPublicKey
89
- this.ourIdentityKey = ourIdentityKey
111
+ this.identityKey = identityKey
90
112
  this.deviceId = deviceId
113
+ this.ownerPublicKey = ownerPublicKey
114
+ this.inviteKeys = inviteKeys
91
115
  this.storage = storage || new InMemoryStorageAdapter()
92
116
  this.versionPrefix = `v${this.storageVersion}`
93
117
  }
@@ -96,62 +120,155 @@ export class SessionManager {
96
120
  if (this.initialized) return
97
121
  this.initialized = true
98
122
 
99
- await this.runMigrations().catch((error) => {
100
- console.error("Failed to run migrations:", error)
123
+ await this.runMigrations().catch(() => {
124
+ // Failed to run migrations
101
125
  })
102
126
 
103
- await this.loadAllUserRecords().catch((error) => {
104
- console.error("Failed to load user records:", error)
127
+ await this.loadAllUserRecords().catch(() => {
128
+ // Failed to load user records
105
129
  })
106
130
 
107
- const ourInviteFromStorage: Invite | null = await this.storage
108
- .get<string>(this.deviceInviteKey(this.deviceId))
109
- .then((data) => {
110
- if (!data) return null
111
- try {
112
- return Invite.deserialize(data)
113
- } catch {
114
- return null
131
+ // Add our own device to user record to prevent accepting our own invite
132
+ // Use ownerPublicKey so delegates are added to the owner's record
133
+ const ourUserRecord = this.getOrCreateUserRecord(this.ownerPublicKey)
134
+ this.upsertDeviceRecord(ourUserRecord, this.deviceId)
135
+
136
+ // Start invite response listener BEFORE setting up users
137
+ // This ensures we're listening when other devices respond to our invites
138
+ this.startInviteResponseListener()
139
+ // Setup sessions with our own other devices
140
+ // Use ownerPublicKey to find sibling devices (important for delegates)
141
+ this.setupUser(this.ownerPublicKey)
142
+ }
143
+
144
+ /**
145
+ * Start listening for invite responses on our ephemeral key.
146
+ * This is used by devices to receive session establishment responses.
147
+ */
148
+ private startInviteResponseListener(): void {
149
+ const { publicKey: ephemeralPubkey, privateKey: ephemeralPrivkey } = this.inviteKeys.ephemeralKeypair
150
+ const sharedSecret = this.inviteKeys.sharedSecret
151
+
152
+ // Subscribe to invite responses tagged to our ephemeral key
153
+ this.ourInviteResponseSubscription = this.nostrSubscribe(
154
+ {
155
+ kinds: [1059], // INVITE_RESPONSE_KIND
156
+ "#p": [ephemeralPubkey],
157
+ },
158
+ async (event) => {
159
+ // Skip already processed InviteResponses (prevents replay issues on restart)
160
+ if (this.processedInviteResponses.has(event.id)) {
161
+ return
115
162
  }
116
- })
163
+ this.processedInviteResponses.add(event.id)
117
164
 
118
- const invite =
119
- ourInviteFromStorage || Invite.createNew(this.ourPublicKey, this.deviceId)
120
-
121
- this.currentDeviceInvite = invite
122
-
123
- await this.storage.put(this.deviceInviteKey(this.deviceId), invite.serialize())
165
+ try {
166
+ const decrypted = await decryptInviteResponse({
167
+ envelopeContent: event.content,
168
+ envelopeSenderPubkey: event.pubkey,
169
+ inviterEphemeralPrivateKey: ephemeralPrivkey,
170
+ inviterPrivateKey: this.identityKey instanceof Uint8Array ? this.identityKey : undefined,
171
+ sharedSecret,
172
+ decrypt: this.identityKey instanceof Uint8Array ? undefined : this.identityKey.decrypt,
173
+ })
174
+
175
+ // Skip our own responses - this happens when we publish an invite response
176
+ // and our own listener receives it back from relays
177
+ // inviteeIdentity serves as the device ID
178
+ if (decrypted.inviteeIdentity === this.deviceId) {
179
+ return
180
+ }
124
181
 
125
- this.ourDeviceInviteSubscription = invite.listen(
126
- this.ourIdentityKey,
127
- this.nostrSubscribe,
128
- async (session, inviteePubkey, deviceId) => {
129
- if (!deviceId || deviceId === this.deviceId) return
130
- const nostrEventId = session.name
131
- const acceptanceKey = this.inviteAcceptKey(nostrEventId, inviteePubkey, deviceId)
132
- const nostrEventIdInStorage = await this.storage.get<string>(acceptanceKey)
133
- if (nostrEventIdInStorage) {
134
- return
135
- }
182
+ // Get owner pubkey from response (required for proper chat routing)
183
+ // If not present (old client), fall back to resolveToOwner
184
+ const claimedOwner = decrypted.ownerPublicKey || this.resolveToOwner(decrypted.inviteeIdentity)
136
185
 
137
- await this.storage.put(acceptanceKey, "1")
186
+ // Verify the device is authorized by fetching owner's AppKeys
187
+ const appKeys = await this.fetchAppKeys(claimedOwner)
138
188
 
139
- const userRecord = this.getOrCreateUserRecord(inviteePubkey)
140
- const deviceRecord = this.upsertDeviceRecord(userRecord, deviceId)
189
+ if (appKeys) {
190
+ const deviceInList = appKeys.getAllDevices().some(
191
+ d => d.identityPubkey === decrypted.inviteeIdentity
192
+ )
193
+ if (!deviceInList) {
194
+ return
195
+ }
196
+ this.updateDelegateMapping(claimedOwner, appKeys)
197
+ } else {
198
+ // No AppKeys - check cached identities or single-device case
199
+ const cachedIdentities = this.userRecords.get(claimedOwner)?.knownDeviceIdentities || []
200
+ const isCached = cachedIdentities.includes(decrypted.inviteeIdentity)
201
+ const isSingleDevice = decrypted.inviteeIdentity === claimedOwner
202
+ if (!isCached && !isSingleDevice) {
203
+ return
204
+ }
205
+ }
141
206
 
142
- this.attachSessionSubscription(inviteePubkey, deviceRecord, session, true)
207
+ const ownerPubkey = claimedOwner
208
+ const userRecord = this.getOrCreateUserRecord(ownerPubkey)
209
+ // inviteeIdentity serves as the device ID
210
+ const deviceRecord = this.upsertDeviceRecord(userRecord, decrypted.inviteeIdentity)
211
+
212
+ const session = createSessionFromAccept({
213
+ nostrSubscribe: this.nostrSubscribe,
214
+ theirPublicKey: decrypted.inviteeSessionPublicKey,
215
+ ourSessionPrivateKey: ephemeralPrivkey,
216
+ sharedSecret,
217
+ isSender: false,
218
+ name: event.id,
219
+ })
220
+
221
+ this.attachSessionSubscription(ownerPubkey, deviceRecord, session, true)
222
+ this.storeUserRecord(ownerPubkey).catch(() => {})
223
+ } catch {
224
+ }
143
225
  }
144
226
  )
227
+ }
145
228
 
146
- if (!this.ourDeviceIntiveTombstoneSubscription) {
147
- this.ourDeviceIntiveTombstoneSubscription = this.createInviteTombstoneSubscription(
148
- this.ourPublicKey
149
- )
150
- }
229
+ /**
230
+ * Fetch a user's AppKeys from relays.
231
+ * Returns null if not found within timeout.
232
+ */
233
+ private fetchAppKeys(pubkey: string, timeoutMs = 2000): Promise<AppKeys | null> {
234
+ return new Promise((resolve) => {
235
+ let latestEvent: { created_at: number; appKeys: AppKeys } | null = null
236
+ let resolved = false
237
+
238
+ // Use a short initial delay before resolving to allow event delivery
239
+ const resolveResult = () => {
240
+ if (resolved) return
241
+ resolved = true
242
+ unsubscribe()
243
+ resolve(latestEvent?.appKeys ?? null)
244
+ }
151
245
 
152
- const inviteNostrEvent = invite.getEvent()
153
- this.nostrPublish(inviteNostrEvent).catch((error) => {
154
- console.error("Failed to publish our device invite:", error)
246
+ // Start timeout
247
+ const timeout = setTimeout(resolveResult, timeoutMs)
248
+
249
+ const unsubscribe = this.nostrSubscribe(
250
+ {
251
+ kinds: [APP_KEYS_EVENT_KIND],
252
+ authors: [pubkey],
253
+ "#d": ["double-ratchet/app-keys"],
254
+ },
255
+ (event) => {
256
+ if (resolved) return
257
+ try {
258
+ const appKeys = AppKeys.fromEvent(event)
259
+ // Use >= to prefer later-delivered events when timestamps are equal
260
+ // This handles replaceable events created within the same second
261
+ if (!latestEvent || event.created_at >= latestEvent.created_at) {
262
+ latestEvent = { created_at: event.created_at, appKeys }
263
+ }
264
+ // Resolve quickly after receiving an event (allow for more events to arrive)
265
+ clearTimeout(timeout)
266
+ setTimeout(resolveResult, 100) // Short delay to collect any late events
267
+ } catch {
268
+ // Invalid event, ignore
269
+ }
270
+ }
271
+ )
155
272
  })
156
273
  }
157
274
 
@@ -161,7 +278,7 @@ export class SessionManager {
161
278
  private getOrCreateUserRecord(userPubkey: string): UserRecord {
162
279
  let rec = this.userRecords.get(userPubkey)
163
280
  if (!rec) {
164
- rec = { publicKey: userPubkey, devices: new Map() }
281
+ rec = { publicKey: userPubkey, devices: new Map(), knownDeviceIdentities: [] }
165
282
  this.userRecords.set(userPubkey, rec)
166
283
  }
167
284
  return rec
@@ -185,56 +302,9 @@ export class SessionManager {
185
302
  return deviceRecord
186
303
  }
187
304
 
188
- private createInviteTombstoneSubscription(authorPublicKey: string): Unsubscribe {
189
- return this.nostrSubscribe(
190
- {
191
- kinds: [INVITE_EVENT_KIND],
192
- authors: [authorPublicKey],
193
- "#l": ["double-ratchet/invites"],
194
- },
195
- (event: VerifiedEvent) => {
196
- try {
197
- const isTombstone = !event.tags?.some(
198
- ([key]) => key === "ephemeralKey" || key === "sharedSecret"
199
- )
200
- if (isTombstone) {
201
- const deviceIdTag = event.tags.find(
202
- ([key, value]) => key === "d" && value.startsWith("double-ratchet/invites/")
203
- )
204
- const [, deviceIdTagValue] = deviceIdTag || []
205
- const deviceId = deviceIdTagValue.split("/").pop()
206
- if (!deviceId) return
207
-
208
- this.cleanupDevice(authorPublicKey, deviceId)
209
- }
210
- } catch (error) {
211
- console.error("Failed to handle device tombstone:", error)
212
- }
213
- }
214
- )
215
- }
216
-
217
305
  private sessionKey(userPubkey: string, deviceId: string, sessionName: string) {
218
306
  return `${this.sessionKeyPrefix(userPubkey)}${deviceId}/${sessionName}`
219
307
  }
220
- private inviteKey(userPubkey: string) {
221
- return this.userInviteKey(userPubkey)
222
- }
223
- private inviteAcceptKey(nostrEventId: string, userPubkey: string, deviceId: string) {
224
- return `${this.inviteAcceptKeyPrefix(userPubkey)}${deviceId}/${nostrEventId}`
225
- }
226
-
227
- private deviceInviteKey(deviceId: string) {
228
- return `${this.versionPrefix}/device-invite/${deviceId}`
229
- }
230
-
231
- private userInviteKey(userPubkey: string) {
232
- return `${this.versionPrefix}/invite/${userPubkey}`
233
- }
234
-
235
- private inviteAcceptKeyPrefix(userPublicKey: string) {
236
- return `${this.versionPrefix}/invite-accept/${userPublicKey}/`
237
- }
238
308
 
239
309
  private sessionKeyPrefix(userPubkey: string) {
240
310
  return `${this.versionPrefix}/session/${userPubkey}/`
@@ -251,6 +321,62 @@ export class SessionManager {
251
321
  return `storage-version`
252
322
  }
253
323
 
324
+ /**
325
+ * Resolve a pubkey to its owner if it's a known delegate device.
326
+ * Returns the input pubkey if not a known delegate.
327
+ */
328
+ private resolveToOwner(pubkey: string): string {
329
+ return this.delegateToOwner.get(pubkey) || pubkey
330
+ }
331
+
332
+ /**
333
+ * Update the delegate-to-owner mapping from an AppKeys.
334
+ * Extracts delegate device pubkeys and maps them to the owner.
335
+ * Persists the mapping in the user record for restart recovery.
336
+ */
337
+ private updateDelegateMapping(ownerPubkey: string, appKeys: AppKeys): void {
338
+ const userRecord = this.getOrCreateUserRecord(ownerPubkey)
339
+ const deviceIdentities = appKeys.getAllDevices()
340
+ .map(d => d.identityPubkey)
341
+ .filter(Boolean) as string[]
342
+
343
+ // Update user record with known device identities
344
+ userRecord.knownDeviceIdentities = deviceIdentities
345
+
346
+ // Update in-memory mapping
347
+ for (const identity of deviceIdentities) {
348
+ this.delegateToOwner.set(identity, ownerPubkey)
349
+ }
350
+
351
+ // Persist
352
+ this.storeUserRecord(ownerPubkey).catch(() => {})
353
+ }
354
+
355
+ private subscribeToUserAppKeys(
356
+ pubkey: string,
357
+ onAppKeys: (list: AppKeys) => void
358
+ ): Unsubscribe {
359
+ return this.nostrSubscribe(
360
+ {
361
+ kinds: [APP_KEYS_EVENT_KIND],
362
+ authors: [pubkey],
363
+ "#d": ["double-ratchet/app-keys"],
364
+ },
365
+ (event) => {
366
+ try {
367
+ const list = AppKeys.fromEvent(event)
368
+ // Update delegate mapping whenever we receive an AppKeys
369
+ this.updateDelegateMapping(pubkey, list)
370
+ onAppKeys(list)
371
+ } catch {
372
+ // Invalid event, ignore
373
+ }
374
+ }
375
+ )
376
+ }
377
+
378
+ private static MAX_INACTIVE_SESSIONS = 10
379
+
254
380
  private attachSessionSubscription(
255
381
  userPubkey: string,
256
382
  deviceRecord: DeviceRecord,
@@ -258,111 +384,212 @@ export class SessionManager {
258
384
  // Set to true if only handshake -> not yet sendable -> will be promoted on message
259
385
  inactive: boolean = false
260
386
  ): void {
261
- if (deviceRecord.staleAt !== undefined) return
262
-
263
387
  const key = this.sessionKey(userPubkey, deviceRecord.deviceId, session.name)
264
- if (this.sessionSubscriptions.has(key)) return
388
+ if (this.sessionSubscriptions.has(key)) {
389
+ return
390
+ }
265
391
 
266
392
  const dr = deviceRecord
267
- const rotateSession = (nextSession: Session) => {
268
- const current = dr.activeSession
269
393
 
270
- if (!current) {
271
- dr.activeSession = nextSession
272
- return
273
- }
394
+ // Promote a session to active when it receives a message
395
+ // Current active goes to top of inactive queue
396
+ const promoteToActive = (nextSession: Session) => {
397
+ const current = dr.activeSession
274
398
 
275
- if (current === nextSession || current.name === nextSession.name) {
276
- dr.activeSession = nextSession
399
+ // Already active, nothing to do
400
+ if (current === nextSession || current?.name === nextSession.name) {
277
401
  return
278
402
  }
279
403
 
404
+ // Remove nextSession from inactive if present
280
405
  dr.inactiveSessions = dr.inactiveSessions.filter(
281
- (session) => session !== current && session.name !== current.name
406
+ (s) => s !== nextSession && s.name !== nextSession.name
282
407
  )
283
408
 
284
- dr.inactiveSessions.push(current)
285
- dr.inactiveSessions = dr.inactiveSessions.slice(-1)
409
+ // Move current active to top of inactive queue
410
+ if (current) {
411
+ dr.inactiveSessions.unshift(current)
412
+ }
413
+
414
+ // Set new active
286
415
  dr.activeSession = nextSession
416
+
417
+ // Trim inactive queue to max size (remove oldest from end)
418
+ if (dr.inactiveSessions.length > SessionManager.MAX_INACTIVE_SESSIONS) {
419
+ const removed = dr.inactiveSessions.splice(SessionManager.MAX_INACTIVE_SESSIONS)
420
+ // Unsubscribe from removed sessions
421
+ for (const s of removed) {
422
+ this.removeSessionSubscription(userPubkey, dr.deviceId, s.name)
423
+ }
424
+ }
287
425
  }
288
426
 
427
+ // Add new session: if inactive, add to top of inactive queue; otherwise set as active
289
428
  if (inactive) {
290
429
  const alreadyTracked = dr.inactiveSessions.some(
291
- (tracked) => tracked === session || tracked.name === session.name
430
+ (s) => s === session || s.name === session.name
292
431
  )
293
432
  if (!alreadyTracked) {
294
- dr.inactiveSessions.push(session)
295
- dr.inactiveSessions = dr.inactiveSessions.slice(-1)
433
+ // Add to top of inactive queue
434
+ dr.inactiveSessions.unshift(session)
435
+ // Trim to max size
436
+ if (dr.inactiveSessions.length > SessionManager.MAX_INACTIVE_SESSIONS) {
437
+ const removed = dr.inactiveSessions.splice(SessionManager.MAX_INACTIVE_SESSIONS)
438
+ for (const s of removed) {
439
+ this.removeSessionSubscription(userPubkey, dr.deviceId, s.name)
440
+ }
441
+ }
296
442
  }
297
443
  } else {
298
- rotateSession(session)
444
+ promoteToActive(session)
299
445
  }
300
446
 
447
+ // Subscribe to session events - when message received, promote to active
301
448
  const unsub = session.onEvent((event) => {
302
449
  for (const cb of this.internalSubscriptions) cb(event, userPubkey)
303
- rotateSession(session)
304
- this.storeUserRecord(userPubkey).catch(console.error)
450
+ promoteToActive(session)
451
+ this.storeUserRecord(userPubkey).catch(() => {})
305
452
  })
306
- this.storeUserRecord(userPubkey).catch(console.error)
453
+ this.storeUserRecord(userPubkey).catch(() => {})
307
454
  this.sessionSubscriptions.set(key, unsub)
308
455
  }
309
456
 
310
- private attachInviteSubscription(
457
+ private attachAppKeysSubscription(
311
458
  userPubkey: string,
312
- onInvite?: (invite: Invite) => void | Promise<void>
459
+ onAppKeys?: (appKeys: AppKeys) => void | Promise<void>
313
460
  ): void {
314
- const key = this.inviteKey(userPubkey)
461
+ const key = `appkeys:${userPubkey}`
315
462
  if (this.inviteSubscriptions.has(key)) return
316
463
 
317
- const unsubscribe = Invite.fromUser(
464
+ const unsubscribe = this.subscribeToUserAppKeys(
318
465
  userPubkey,
319
- this.nostrSubscribe,
320
- async (invite) => {
321
- if (!invite.deviceId) return
322
- if (onInvite) await onInvite(invite)
466
+ async (appKeys) => {
467
+ if (onAppKeys) await onAppKeys(appKeys)
323
468
  }
324
469
  )
325
470
 
326
471
  this.inviteSubscriptions.set(key, unsubscribe)
327
472
  }
328
473
 
329
- private attachInviteTombstoneSubscription(userPubkey: string): void {
330
- if (this.inviteTombstoneSubscriptions.has(userPubkey)) {
331
- return
332
- }
333
-
334
- const unsubscribe = this.createInviteTombstoneSubscription(userPubkey)
335
- this.inviteTombstoneSubscriptions.set(userPubkey, unsubscribe)
336
- }
337
-
338
474
  setupUser(userPubkey: string) {
339
475
  const userRecord = this.getOrCreateUserRecord(userPubkey)
340
476
 
341
- this.attachInviteTombstoneSubscription(userPubkey)
477
+ // Track which device identities we've subscribed to for invites
478
+ const subscribedDeviceIdentities = new Set<string>()
479
+ // Track devices currently being accepted (to prevent duplicate acceptance)
480
+ const pendingAcceptances = new Set<string>()
481
+
482
+ /**
483
+ * Accept an invite from a device.
484
+ * The invite is fetched separately from the device's own Invite event.
485
+ */
486
+ const acceptInviteFromDevice = async (
487
+ device: DeviceEntry,
488
+ invite: Invite
489
+ ) => {
490
+ // Double-check for active session (race condition guard)
491
+ // Another concurrent call may have already established a session
492
+ const existingRecord = userRecord.devices.get(device.identityPubkey)
493
+ if (existingRecord?.activeSession) {
494
+ return
495
+ }
342
496
 
343
- const acceptInvite = async (invite: Invite) => {
344
- const { deviceId } = invite
345
- if (!deviceId) return
497
+ // Add device record IMMEDIATELY to prevent duplicate acceptance from race conditions
498
+ // Use identityPubkey as the device identifier
499
+ const deviceRecord = this.upsertDeviceRecord(userRecord, device.identityPubkey)
346
500
 
501
+ const encryptor = this.identityKey instanceof Uint8Array ? this.identityKey : this.identityKey.encrypt
502
+ // ourPublicKey serves as both identity and device ID
347
503
  const { session, event } = await invite.accept(
348
504
  this.nostrSubscribe,
349
505
  this.ourPublicKey,
350
- this.ourIdentityKey,
351
- this.deviceId
506
+ encryptor,
507
+ this.ownerPublicKey
352
508
  )
353
509
  return this.nostrPublish(event)
354
- .then(() => this.upsertDeviceRecord(userRecord, deviceId))
355
- .then((dr) => this.attachSessionSubscription(userPubkey, dr, session))
356
- .then(() => this.sendMessageHistory(userPubkey, deviceId))
357
- .catch(console.error)
510
+ .then(() => {
511
+ this.attachSessionSubscription(userPubkey, deviceRecord, session)
512
+ })
513
+ .then(() => this.sendMessageHistory(userPubkey, device.identityPubkey))
514
+ .catch(() => {})
515
+ }
516
+
517
+ /**
518
+ * Subscribe to a device's Invite event and accept it when received.
519
+ */
520
+ const subscribeToDeviceInvite = (device: DeviceEntry) => {
521
+ // identityPubkey is the device identifier
522
+ const deviceKey = device.identityPubkey
523
+ if (subscribedDeviceIdentities.has(deviceKey)) {
524
+ return
525
+ }
526
+ subscribedDeviceIdentities.add(deviceKey)
527
+
528
+ // Already have a record with active session for this device? Skip.
529
+ const existingRecord = userRecord.devices.get(device.identityPubkey)
530
+ if (existingRecord?.activeSession) {
531
+ return
532
+ }
533
+
534
+ const inviteSubKey = `invite:${device.identityPubkey}`
535
+ if (this.inviteSubscriptions.has(inviteSubKey)) {
536
+ return
537
+ }
538
+
539
+ // Subscribe to this device's Invite event
540
+ const unsub = Invite.fromUser(device.identityPubkey, this.nostrSubscribe, async (invite) => {
541
+ // Verify the invite is for this device (identityPubkey is the device identifier)
542
+ if (invite.deviceId !== device.identityPubkey) {
543
+ return
544
+ }
545
+
546
+ // Skip if we already have an active session (race condition guard)
547
+ const existingDeviceRecord = userRecord.devices.get(device.identityPubkey)
548
+ if (existingDeviceRecord?.activeSession) {
549
+ return
550
+ }
551
+
552
+ // Skip if acceptance is already in progress (race condition guard)
553
+ if (pendingAcceptances.has(device.identityPubkey)) {
554
+ return
555
+ }
556
+
557
+ pendingAcceptances.add(device.identityPubkey)
558
+ try {
559
+ await acceptInviteFromDevice(device, invite)
560
+ } finally {
561
+ pendingAcceptances.delete(device.identityPubkey)
562
+ }
563
+ })
564
+
565
+ this.inviteSubscriptions.set(inviteSubKey, unsub)
358
566
  }
359
567
 
360
- this.attachInviteSubscription(userPubkey, async (invite) => {
361
- const { deviceId } = invite
362
- if (!deviceId) return
568
+ this.attachAppKeysSubscription(userPubkey, async (appKeys) => {
569
+ const devices = appKeys.getAllDevices()
570
+ const activeDeviceIds = new Set(devices.map(d => d.identityPubkey))
571
+
572
+ // Handle devices no longer in list (revoked or AppKeys recreated from scratch)
573
+ const userRecord = this.userRecords.get(userPubkey)
574
+ if (userRecord) {
575
+ for (const [deviceId] of userRecord.devices) {
576
+ if (!activeDeviceIds.has(deviceId)) {
577
+ // Remove from tracking so device can be re-subscribed if re-added
578
+ subscribedDeviceIdentities.delete(deviceId)
579
+ const inviteSubKey = `invite:${deviceId}`
580
+ const inviteUnsub = this.inviteSubscriptions.get(inviteSubKey)
581
+ if (inviteUnsub) {
582
+ inviteUnsub()
583
+ this.inviteSubscriptions.delete(inviteSubKey)
584
+ }
585
+ await this.cleanupDevice(userPubkey, deviceId)
586
+ }
587
+ }
588
+ }
363
589
 
364
- if (!userRecord.devices.has(deviceId)) {
365
- await acceptInvite(invite)
590
+ // For each device in AppKeys, subscribe to their Invite event
591
+ for (const device of devices) {
592
+ subscribeToDeviceInvite(device)
366
593
  }
367
594
  })
368
595
  }
@@ -379,10 +606,6 @@ export class SessionManager {
379
606
  return this.deviceId
380
607
  }
381
608
 
382
- getDeviceInviteEphemeralKey(): string | null {
383
- return this.currentDeviceInvite?.inviterEphemeralPublicKey || null
384
- }
385
-
386
609
  getUserRecords(): Map<string, UserRecord> {
387
610
  return this.userRecords
388
611
  }
@@ -396,12 +619,7 @@ export class SessionManager {
396
619
  unsubscribe()
397
620
  }
398
621
 
399
- for (const unsubscribe of this.inviteTombstoneSubscriptions.values()) {
400
- unsubscribe()
401
- }
402
-
403
- this.ourDeviceInviteSubscription?.()
404
- this.ourDeviceIntiveTombstoneSubscription?.()
622
+ this.ourInviteResponseSubscription?.()
405
623
  }
406
624
 
407
625
  deactivateCurrentSessions(publicKey: string) {
@@ -413,7 +631,7 @@ export class SessionManager {
413
631
  device.activeSession = undefined
414
632
  }
415
633
  }
416
- this.storeUserRecord(publicKey).catch(console.error)
634
+ this.storeUserRecord(publicKey).catch(() => {})
417
635
  }
418
636
 
419
637
  async deleteUser(userPubkey: string): Promise<void> {
@@ -439,23 +657,16 @@ export class SessionManager {
439
657
  this.userRecords.delete(userPubkey)
440
658
  }
441
659
 
442
- const inviteKey = this.inviteKey(userPubkey)
443
- const inviteUnsub = this.inviteSubscriptions.get(inviteKey)
444
- if (inviteUnsub) {
445
- inviteUnsub()
446
- this.inviteSubscriptions.delete(inviteKey)
447
- }
448
-
449
- const tombstoneUnsub = this.inviteTombstoneSubscriptions.get(userPubkey)
450
- if (tombstoneUnsub) {
451
- tombstoneUnsub()
452
- this.inviteTombstoneSubscriptions.delete(userPubkey)
660
+ const appKeysKey = `appkeys:${userPubkey}`
661
+ const appKeysUnsub = this.inviteSubscriptions.get(appKeysKey)
662
+ if (appKeysUnsub) {
663
+ appKeysUnsub()
664
+ this.inviteSubscriptions.delete(appKeysKey)
453
665
  }
454
666
 
455
667
  this.messageHistory.delete(userPubkey)
456
668
 
457
669
  await Promise.allSettled([
458
- this.storage.del(this.inviteKey(userPubkey)),
459
670
  this.deleteUserSessionsFromStorage(userPubkey),
460
671
  this.storage.del(this.userRecordKey(userPubkey)),
461
672
  ])
@@ -493,13 +704,12 @@ export class SessionManager {
493
704
  if (!device) {
494
705
  return
495
706
  }
496
- if (device.staleAt !== undefined) {
497
- return
498
- }
499
707
  for (const event of history) {
500
708
  const { activeSession } = device
501
709
 
502
- if (!activeSession) continue
710
+ if (!activeSession) {
711
+ continue
712
+ }
503
713
  const { event: verifiedEvent } = activeSession.sendEvent(event)
504
714
  await this.nostrPublish(verifiedEvent)
505
715
  await this.storeUserRecord(recipientPublicKey)
@@ -514,36 +724,56 @@ export class SessionManager {
514
724
 
515
725
  // Add to message history queue (will be sent when session is established)
516
726
  const completeEvent = event as Rumor
517
- const historyTargets = new Set([recipientIdentityKey, this.ourPublicKey])
727
+ // Use ownerPublicKey for history targets so delegates share history with owner
728
+ const historyTargets = new Set([recipientIdentityKey, this.ownerPublicKey])
518
729
  for (const key of historyTargets) {
519
730
  const existing = this.messageHistory.get(key) || []
520
731
  this.messageHistory.set(key, [...existing, completeEvent])
521
732
  }
522
733
 
523
734
  const userRecord = this.getOrCreateUserRecord(recipientIdentityKey)
524
- const ourUserRecord = this.getOrCreateUserRecord(this.ourPublicKey)
735
+ // Use ownerPublicKey to find sibling devices (important for delegates)
736
+ const ourUserRecord = this.getOrCreateUserRecord(this.ownerPublicKey)
525
737
 
526
738
  this.setupUser(recipientIdentityKey)
527
- this.setupUser(this.ourPublicKey)
528
-
529
- const devices = [
530
- ...Array.from(userRecord.devices.values()),
531
- ...Array.from(ourUserRecord.devices.values()),
532
- ].filter((device) => device.staleAt === undefined)
739
+ // Use ownerPublicKey to setup sessions with sibling devices
740
+ this.setupUser(this.ownerPublicKey)
741
+
742
+ const recipientDevices = Array.from(userRecord.devices.values())
743
+ const ownDevices = Array.from(ourUserRecord.devices.values())
744
+
745
+ // Merge and deduplicate by deviceId, excluding our own sending device
746
+ // This fixes the self-message bug where sending to yourself would duplicate devices
747
+ const deviceMap = new Map<string, DeviceRecord>()
748
+ for (const d of [...recipientDevices, ...ownDevices]) {
749
+ if (d.deviceId !== this.deviceId) { // Exclude sender's own device
750
+ deviceMap.set(d.deviceId, d)
751
+ }
752
+ }
753
+ const devices = Array.from(deviceMap.values())
533
754
 
534
- // Send to all devices in background (if sessions exist)
535
- Promise.allSettled(
755
+ // Send to all devices and await completion before returning
756
+ // This ensures session state is ratcheted and persisted before function returns
757
+ await Promise.allSettled(
536
758
  devices.map(async (device) => {
537
759
  const { activeSession } = device
538
- if (!activeSession) return
760
+ if (!activeSession) {
761
+ return
762
+ }
539
763
  const { event: verifiedEvent } = activeSession.sendEvent(event)
540
- await this.nostrPublish(verifiedEvent).catch(console.error)
764
+ await this.nostrPublish(verifiedEvent).catch(() => {})
541
765
  })
542
766
  )
543
- .then(() => {
544
- this.storeUserRecord(recipientIdentityKey)
545
- })
546
- .catch(console.error)
767
+
768
+ // Store recipient's user record after all messages sent
769
+ await this.storeUserRecord(recipientIdentityKey)
770
+ // Also store owner's record if different (for sibling device sessions)
771
+ // This ensures session state is persisted after ratcheting for both:
772
+ // - recipientDevices stored under recipientIdentityKey
773
+ // - Own sibling devices stored under ownerPublicKey
774
+ if (this.ownerPublicKey !== recipientIdentityKey) {
775
+ await this.storeUserRecord(this.ownerPublicKey)
776
+ }
547
777
 
548
778
  // Return the event with computed ID (same as library would compute)
549
779
  return completeEvent
@@ -576,58 +806,30 @@ export class SessionManager {
576
806
  rumor.id = getEventHash(rumor)
577
807
 
578
808
  // Use sendEvent for actual sending (includes queueing)
579
- this.sendEvent(recipientPublicKey, rumor).catch(console.error)
809
+ // Note: sendEvent is not awaited to maintain backward compatibility
810
+ // The message is queued and will be sent when sessions are established
811
+ this.sendEvent(recipientPublicKey, rumor).catch(() => {})
580
812
 
581
813
  return rumor
582
814
  }
583
815
 
584
- async revokeDevice(deviceId: string): Promise<void> {
585
- await this.init()
586
-
587
- await this.publishDeviceTombstone(deviceId).catch((error) => {
588
- console.error("Failed to publish device tombstone:", error)
589
- })
590
-
591
- await this.cleanupDevice(this.ourPublicKey, deviceId)
592
- }
593
-
594
- private async publishDeviceTombstone(deviceId: string): Promise<void> {
595
- const tags: string[][] = [
596
- ["l", "double-ratchet/invites"],
597
- ["d", `double-ratchet/invites/${deviceId}`],
598
- ]
599
-
600
- const deletionEvent = {
601
- content: "",
602
- kind: INVITE_EVENT_KIND,
603
- created_at: Math.floor(Date.now() / 1000),
604
- tags,
605
- pubkey: this.ourPublicKey,
606
- }
607
-
608
- await this.nostrPublish(deletionEvent)
609
- }
610
-
611
816
  private async cleanupDevice(publicKey: string, deviceId: string): Promise<void> {
612
817
  const userRecord = this.userRecords.get(publicKey)
613
818
  if (!userRecord) return
614
819
  const deviceRecord = userRecord.devices.get(deviceId)
615
-
616
820
  if (!deviceRecord) return
617
821
 
822
+ // Unsubscribe from sessions
618
823
  if (deviceRecord.activeSession) {
619
824
  this.removeSessionSubscription(publicKey, deviceId, deviceRecord.activeSession.name)
620
825
  }
621
-
622
826
  for (const session of deviceRecord.inactiveSessions) {
623
827
  this.removeSessionSubscription(publicKey, deviceId, session.name)
624
828
  }
625
829
 
626
- deviceRecord.activeSession = undefined
627
- deviceRecord.inactiveSessions = []
628
- deviceRecord.staleAt = Date.now()
629
-
630
- await this.storeUserRecord(publicKey).catch(console.error)
830
+ // Delete the device record entirely
831
+ userRecord.devices.delete(deviceId)
832
+ await this.storeUserRecord(publicKey).catch(() => {})
631
833
  }
632
834
 
633
835
  private buildMessageTags(
@@ -644,21 +846,26 @@ export class SessionManager {
644
846
  }
645
847
 
646
848
  private storeUserRecord(publicKey: string) {
849
+ const userRecord = this.userRecords.get(publicKey)
850
+ const devices = Array.from(userRecord?.devices.entries() || [])
851
+ const serializeSession = (session: Session): StoredSessionEntry => ({
852
+ name: session.name,
853
+ state: serializeSessionState(session.state)
854
+ })
855
+
647
856
  const data: StoredUserRecord = {
648
857
  publicKey: publicKey,
649
- devices: Array.from(this.userRecords.get(publicKey)?.devices.entries() || []).map(
858
+ devices: devices.map(
650
859
  ([, device]) => ({
651
860
  deviceId: device.deviceId,
652
861
  activeSession: device.activeSession
653
- ? serializeSessionState(device.activeSession.state)
862
+ ? serializeSession(device.activeSession)
654
863
  : null,
655
- inactiveSessions: device.inactiveSessions.map((session) =>
656
- serializeSessionState(session.state)
657
- ),
864
+ inactiveSessions: device.inactiveSessions.map(serializeSession),
658
865
  createdAt: device.createdAt,
659
- staleAt: device.staleAt,
660
866
  })
661
867
  ),
868
+ knownDeviceIdentities: userRecord?.knownDeviceIdentities || [],
662
869
  }
663
870
  return this.storage.put(this.userRecordKey(publicKey), data)
664
871
  }
@@ -671,65 +878,66 @@ export class SessionManager {
671
878
 
672
879
  const devices = new Map<string, DeviceRecord>()
673
880
 
881
+ const deserializeSession = (entry: StoredSessionEntry): Session => {
882
+ const session = new Session(this.nostrSubscribe, deserializeSessionState(entry.state))
883
+ session.name = entry.name
884
+ this.processedInviteResponses.add(entry.name)
885
+ return session
886
+ }
887
+
674
888
  for (const deviceData of data.devices) {
675
889
  const {
676
890
  deviceId,
677
891
  activeSession: serializedActive,
678
892
  inactiveSessions: serializedInactive,
679
893
  createdAt,
680
- staleAt,
681
894
  } = deviceData
682
895
 
683
896
  try {
684
897
  const activeSession = serializedActive
685
- ? new Session(
686
- this.nostrSubscribe,
687
- deserializeSessionState(serializedActive)
688
- )
898
+ ? deserializeSession(serializedActive)
689
899
  : undefined
690
900
 
691
- const inactiveSessions = serializedInactive.map(
692
- (entry) => new Session(this.nostrSubscribe, deserializeSessionState(entry))
693
- )
901
+ const inactiveSessions = serializedInactive.map(deserializeSession)
694
902
 
695
903
  devices.set(deviceId, {
696
904
  deviceId,
697
905
  activeSession,
698
906
  inactiveSessions,
699
907
  createdAt,
700
- staleAt,
701
908
  })
702
- } catch (e) {
703
- console.error(
704
- `Failed to deserialize session for user ${publicKey}, device ${deviceId}:`,
705
- e
706
- )
909
+ } catch {
910
+ // Failed to deserialize session
707
911
  }
708
912
  }
709
913
 
914
+ const knownDeviceIdentities = data.knownDeviceIdentities || []
915
+
710
916
  this.userRecords.set(publicKey, {
711
917
  publicKey: data.publicKey,
712
918
  devices,
919
+ knownDeviceIdentities,
713
920
  })
714
921
 
715
- if (publicKey !== this.ourPublicKey) {
716
- this.attachInviteTombstoneSubscription(publicKey)
922
+ // Rebuild delegateToOwner mapping from stored device identities
923
+ for (const identity of knownDeviceIdentities) {
924
+ this.delegateToOwner.set(identity, publicKey)
717
925
  }
718
926
 
719
927
  for (const device of devices.values()) {
720
- const { deviceId, activeSession, inactiveSessions, staleAt } = device
721
- if (!deviceId || staleAt !== undefined) continue
928
+ const { deviceId, activeSession, inactiveSessions } = device
929
+ if (!deviceId) continue
722
930
 
723
931
  for (const session of inactiveSessions.reverse()) {
724
- this.attachSessionSubscription(publicKey, device, session)
932
+ this.attachSessionSubscription(publicKey, device, session, true) // Restore as inactive
725
933
  }
726
934
  if (activeSession) {
727
- this.attachSessionSubscription(publicKey, device, activeSession)
935
+ this.attachSessionSubscription(publicKey, device, activeSession) // Restore as active
728
936
  }
729
937
  }
730
938
  })
731
- .catch((error) => {
732
- console.error(`Failed to load user record for ${publicKey}:`, error)
939
+ .catch(() => {
940
+ // Failed to load user record
733
941
  })
734
942
  }
735
943
 
@@ -751,34 +959,12 @@ export class SessionManager {
751
959
 
752
960
  // First migration
753
961
  if (!version) {
754
- // Fetch all existing invites
755
- // Assume no version prefix
756
- // Deserialize and serialize to start using persistent createdAt
757
- // Re-save invites with proper keys
962
+ // Delete old invite data (legacy format no longer supported)
758
963
  const oldInvitePrefix = "invite/"
759
964
  const inviteKeys = await this.storage.list(oldInvitePrefix)
760
- await Promise.all(
761
- inviteKeys.map(async (key) => {
762
- try {
763
- const publicKey = key.slice(oldInvitePrefix.length)
764
- const inviteData = await this.storage.get<string>(key)
765
- if (inviteData) {
766
- const newKey = this.userInviteKey(publicKey)
767
- const invite = Invite.deserialize(inviteData)
768
- const serializedInvite = invite.serialize()
769
- await this.storage.put(newKey, serializedInvite)
770
- await this.storage.del(key)
771
- }
772
- } catch (e) {
773
- console.error("Migration error for invite:", e)
774
- }
775
- })
776
- )
965
+ await Promise.all(inviteKeys.map((key) => this.storage.del(key)))
777
966
 
778
- // Fetch all existing user records
779
- // Assume no version prefix
780
- // Remove all old sessions as these may have key issues
781
- // Re-save user records without sessions with proper keys
967
+ // Migrate old user records (clear sessions, keep device records)
782
968
  const oldUserRecordPrefix = "user/"
783
969
  const sessionKeys = await this.storage.list(oldUserRecordPrefix)
784
970
  await Promise.all(
@@ -800,22 +986,14 @@ export class SessionManager {
800
986
  await this.storage.put(newKey, newUserRecordData)
801
987
  await this.storage.del(key)
802
988
  }
803
- } catch (e) {
804
- console.error("Migration error for user record:", e)
989
+ } catch {
990
+ // Migration error for user record
805
991
  }
806
992
  })
807
993
  )
808
994
 
809
- // Set version to 1 so next migration can run
810
995
  version = "1"
811
996
  await this.storage.put(this.versionKey(), version)
812
-
813
- return
814
- }
815
-
816
- // Future migrations
817
- if (version === "1") {
818
- return
819
997
  }
820
998
  }
821
999
  }