nostr-double-ratchet 0.0.37 → 0.0.38

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/dist/types.d.ts CHANGED
@@ -55,6 +55,14 @@ export type Unsubscribe = () => void;
55
55
  export type NostrSubscribe = (_filter: Filter, _onEvent: (_e: VerifiedEvent) => void) => Unsubscribe;
56
56
  export type EncryptFunction = (_plaintext: string, _pubkey: string) => Promise<string>;
57
57
  export type DecryptFunction = (_ciphertext: string, _pubkey: string) => Promise<string>;
58
+ /**
59
+ * Identity key for cryptographic operations.
60
+ * Either a raw private key (Uint8Array) or encrypt/decrypt functions for extension login (NIP-07).
61
+ */
62
+ export type IdentityKey = Uint8Array | {
63
+ encrypt: EncryptFunction;
64
+ decrypt: DecryptFunction;
65
+ };
58
66
  export type NostrPublish = (_event: UnsignedEvent) => Promise<VerifiedEvent>;
59
67
  export type Rumor = UnsignedEvent & {
60
68
  id: string;
@@ -74,6 +82,10 @@ export declare const MESSAGE_EVENT_KIND = 1060;
74
82
  */
75
83
  export declare const INVITE_EVENT_KIND = 30078;
76
84
  export declare const INVITE_RESPONSE_KIND = 1059;
85
+ /**
86
+ * Invite list event kind (replaceable - one per user)
87
+ */
88
+ export declare const INVITE_LIST_EVENT_KIND = 10078;
77
89
  export declare const CHAT_MESSAGE_KIND = 14;
78
90
  export declare const MAX_SKIP = 100;
79
91
  export type NostrEvent = {
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEnE,MAAM,MAAM,MAAM,GAAG;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,mBAAmB,EAAE,MAAM,CAAC;IAC5B,aAAa,EAAE,MAAM,CAAC;CACvB,CAAA;AAED;;GAEG;AACH,MAAM,MAAM,OAAO,GAAG;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,UAAU,CAAC;CACxB,CAAA;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,iEAAiE;IACjE,OAAO,EAAE,UAAU,CAAC;IAEpB,iDAAiD;IACjD,0BAA0B,CAAC,EAAE,MAAM,CAAC;IAEpC,8CAA8C;IAC9C,uBAAuB,EAAE,MAAM,CAAC;IAEhC,sDAAsD;IACtD,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAE7B,kGAAkG;IAClG,eAAe,EAAE,OAAO,CAAC;IAEzB,4DAA4D;IAC5D,iBAAiB,CAAC,EAAE,UAAU,CAAC;IAE/B,4DAA4D;IAC5D,eAAe,CAAC,EAAE,UAAU,CAAC;IAE7B,uDAAuD;IACvD,yBAAyB,EAAE,MAAM,CAAC;IAElC,6DAA6D;IAC7D,2BAA2B,EAAE,MAAM,CAAC;IAEpC,wDAAwD;IACxD,gCAAgC,EAAE,MAAM,CAAC;IAEzC,wEAAwE;IACxE,WAAW,EAAE;QACX,CAAC,MAAM,EAAE,MAAM,GAAG;YAChB,UAAU,EAAE,UAAU,EAAE,CAAC;YACzB,WAAW,EAAE;gBAAC,CAAC,QAAQ,EAAE,MAAM,GAAG,UAAU,CAAA;aAAC,CAAA;SAC9C,CAAC;KACH,CAAC;CACH;AAED;;GAEG;AACH,MAAM,MAAM,WAAW,GAAG,MAAM,IAAI,CAAC;AAErC;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,EAAE,EAAE,aAAa,KAAK,IAAI,KAAK,WAAW,CAAC;AACrG,MAAM,MAAM,eAAe,GAAG,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;AACvF,MAAM,MAAM,eAAe,GAAG,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;AACxF,MAAM,MAAM,YAAY,GAAG,CAAC,MAAM,EAAE,aAAa,KAAK,OAAO,CAAC,aAAa,CAAC,CAAC;AAE7E,MAAM,MAAM,KAAK,GAAG,aAAa,GAAG;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAAA;AAElD;;;;GAIG;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,aAAa,KAAK,IAAI,CAAC;AAEhF;;GAEG;AACH,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC;;GAEG;AACH,eAAO,MAAM,iBAAiB,QAAQ,CAAC;AAEvC,eAAO,MAAM,oBAAoB,OAAO,CAAC;AAEzC,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAEpC,eAAO,MAAM,QAAQ,MAAM,CAAC;AAE5B,MAAM,MAAM,UAAU,GAAG;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;CACb,CAAA"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nostr-double-ratchet",
3
- "version": "0.0.37",
3
+ "version": "0.0.38",
4
4
  "type": "module",
5
5
  "packageManager": "yarn@1.22.22",
6
6
  "description": "Nostr double ratchet library",
@@ -0,0 +1,565 @@
1
+ import { generateSecretKey, getPublicKey, VerifiedEvent } from "nostr-tools"
2
+ import { bytesToHex } from "@noble/hashes/utils"
3
+ import { InviteList, DeviceEntry } from "./InviteList"
4
+ import { DevicePayload } from "./inviteUtils"
5
+ import { NostrSubscribe, NostrPublish, INVITE_LIST_EVENT_KIND, Unsubscribe, IdentityKey } from "./types"
6
+ import { StorageAdapter, InMemoryStorageAdapter } from "./StorageAdapter"
7
+ import { SessionManager } from "./SessionManager"
8
+
9
+ export interface OwnerDeviceOptions {
10
+ ownerPublicKey: string
11
+ identityKey: IdentityKey
12
+ deviceId: string
13
+ deviceLabel: string
14
+ nostrSubscribe: NostrSubscribe
15
+ nostrPublish: NostrPublish
16
+ storage?: StorageAdapter
17
+ }
18
+
19
+ export interface DelegateDeviceOptions {
20
+ deviceId: string
21
+ deviceLabel: string
22
+ nostrSubscribe: NostrSubscribe
23
+ nostrPublish: NostrPublish
24
+ storage?: StorageAdapter
25
+ }
26
+
27
+ export interface RestoreDelegateOptions {
28
+ deviceId: string
29
+ devicePublicKey: string
30
+ devicePrivateKey: Uint8Array
31
+ ephemeralPublicKey: string
32
+ ephemeralPrivateKey: Uint8Array
33
+ sharedSecret: string
34
+ nostrSubscribe: NostrSubscribe
35
+ nostrPublish: NostrPublish
36
+ storage?: StorageAdapter
37
+ }
38
+
39
+ export interface CreateDelegateResult {
40
+ manager: DelegateDeviceManager
41
+ payload: DevicePayload
42
+ }
43
+
44
+ export interface IDeviceManager {
45
+ init(): Promise<void>
46
+ getDeviceId(): string
47
+ getIdentityPublicKey(): string
48
+ getIdentityKey(): IdentityKey
49
+ getEphemeralKeypair(): { publicKey: string; privateKey: Uint8Array } | null
50
+ getSharedSecret(): string | null
51
+ getOwnerPublicKey(): string | null
52
+ close(): void
53
+ createSessionManager(sessionStorage?: StorageAdapter): SessionManager
54
+ }
55
+
56
+ /** Owner's main device. Has identity key and can manage InviteList. */
57
+ export class OwnerDeviceManager implements IDeviceManager {
58
+ private readonly deviceId: string
59
+ private readonly deviceLabel: string
60
+ private readonly nostrSubscribe: NostrSubscribe
61
+ private readonly nostrPublish: NostrPublish
62
+ private readonly storage: StorageAdapter
63
+ private readonly ownerPublicKey: string
64
+ private readonly identityKey: IdentityKey
65
+
66
+ private inviteList: InviteList | null = null
67
+ private initialized = false
68
+ private subscriptions: Unsubscribe[] = []
69
+
70
+ private readonly storageVersion = "1"
71
+ private get versionPrefix(): string {
72
+ return `v${this.storageVersion}`
73
+ }
74
+
75
+ constructor(options: OwnerDeviceOptions) {
76
+ this.deviceId = options.deviceId
77
+ this.deviceLabel = options.deviceLabel
78
+ this.nostrSubscribe = options.nostrSubscribe
79
+ this.nostrPublish = options.nostrPublish
80
+ this.storage = options.storage || new InMemoryStorageAdapter()
81
+ this.ownerPublicKey = options.ownerPublicKey
82
+ this.identityKey = options.identityKey
83
+ }
84
+
85
+ async init(): Promise<void> {
86
+ if (this.initialized) return
87
+ this.initialized = true
88
+
89
+ const local = await this.loadInviteList()
90
+ const remote = await this.fetchInviteList(this.ownerPublicKey)
91
+ const inviteList = this.mergeInviteLists(local, remote)
92
+
93
+ if (!inviteList.getDevice(this.deviceId)) {
94
+ const device = inviteList.createDevice(this.deviceLabel, this.deviceId)
95
+ inviteList.addDevice(device)
96
+ }
97
+
98
+ this.inviteList = inviteList
99
+ await this.saveInviteList(inviteList)
100
+
101
+ const event = inviteList.getEvent()
102
+ await this.nostrPublish(event).catch((error) => {
103
+ console.error("Failed to publish InviteList:", error)
104
+ })
105
+
106
+ this.subscribeToOwnInviteList()
107
+ }
108
+
109
+ getDeviceId(): string {
110
+ return this.deviceId
111
+ }
112
+
113
+ getIdentityPublicKey(): string {
114
+ return this.ownerPublicKey
115
+ }
116
+
117
+ getIdentityKey(): IdentityKey {
118
+ return this.identityKey
119
+ }
120
+
121
+ getEphemeralKeypair(): { publicKey: string; privateKey: Uint8Array } | null {
122
+ const device = this.inviteList?.getDevice(this.deviceId)
123
+ if (!device?.ephemeralPublicKey || !device?.ephemeralPrivateKey) {
124
+ return null
125
+ }
126
+ return {
127
+ publicKey: device.ephemeralPublicKey,
128
+ privateKey: device.ephemeralPrivateKey,
129
+ }
130
+ }
131
+
132
+ getSharedSecret(): string | null {
133
+ const device = this.inviteList?.getDevice(this.deviceId)
134
+ return device?.sharedSecret || null
135
+ }
136
+
137
+ getOwnerPublicKey(): string {
138
+ return this.ownerPublicKey
139
+ }
140
+
141
+ getInviteList(): InviteList | null {
142
+ return this.inviteList
143
+ }
144
+
145
+ getOwnDevices(): DeviceEntry[] {
146
+ return this.inviteList?.getAllDevices() || []
147
+ }
148
+
149
+ async addDevice(payload: DevicePayload): Promise<void> {
150
+ await this.init()
151
+
152
+ await this.modifyInviteList((list) => {
153
+ const device: DeviceEntry = {
154
+ ephemeralPublicKey: payload.ephemeralPubkey,
155
+ sharedSecret: payload.sharedSecret,
156
+ deviceId: payload.deviceId,
157
+ deviceLabel: payload.deviceLabel,
158
+ createdAt: Math.floor(Date.now() / 1000),
159
+ identityPubkey: payload.identityPubkey,
160
+ }
161
+ list.addDevice(device)
162
+ })
163
+ }
164
+
165
+ async revokeDevice(deviceId: string): Promise<void> {
166
+ if (deviceId === this.deviceId) {
167
+ throw new Error("Cannot revoke own device")
168
+ }
169
+
170
+ await this.init()
171
+
172
+ await this.modifyInviteList((list) => {
173
+ list.removeDevice(deviceId)
174
+ })
175
+ }
176
+
177
+ async updateDeviceLabel(deviceId: string, label: string): Promise<void> {
178
+ await this.init()
179
+
180
+ await this.modifyInviteList((list) => {
181
+ list.updateDeviceLabel(deviceId, label)
182
+ })
183
+ }
184
+
185
+ close(): void {
186
+ for (const unsubscribe of this.subscriptions) {
187
+ unsubscribe()
188
+ }
189
+ this.subscriptions = []
190
+ }
191
+
192
+ createSessionManager(sessionStorage?: StorageAdapter): SessionManager {
193
+ if (!this.initialized) {
194
+ throw new Error("DeviceManager must be initialized before creating SessionManager")
195
+ }
196
+
197
+ const ephemeralKeypair = this.getEphemeralKeypair()
198
+ const sharedSecret = this.getSharedSecret()
199
+
200
+ if (!ephemeralKeypair || !sharedSecret) {
201
+ throw new Error("Ephemeral keypair and shared secret required for SessionManager")
202
+ }
203
+
204
+ return new SessionManager(
205
+ this.ownerPublicKey,
206
+ this.identityKey,
207
+ this.deviceId,
208
+ this.nostrSubscribe,
209
+ this.nostrPublish,
210
+ this.ownerPublicKey,
211
+ { ephemeralKeypair, sharedSecret },
212
+ sessionStorage || this.storage,
213
+ )
214
+ }
215
+
216
+ private inviteListKey(): string {
217
+ return `${this.versionPrefix}/device-manager/invite-list`
218
+ }
219
+
220
+ private async loadInviteList(): Promise<InviteList | null> {
221
+ const data = await this.storage.get<string>(this.inviteListKey())
222
+ if (!data) return null
223
+ try {
224
+ return InviteList.deserialize(data)
225
+ } catch {
226
+ return null
227
+ }
228
+ }
229
+
230
+ private async saveInviteList(list: InviteList): Promise<void> {
231
+ await this.storage.put(this.inviteListKey(), list.serialize())
232
+ }
233
+
234
+ private fetchInviteList(pubkey: string, timeoutMs = 500): Promise<InviteList | null> {
235
+ return new Promise((resolve) => {
236
+ let latestEvent: { event: VerifiedEvent; inviteList: InviteList } | null = null
237
+ let resolved = false
238
+
239
+ setTimeout(() => {
240
+ if (resolved) return
241
+ resolved = true
242
+ unsubscribe()
243
+ resolve(latestEvent?.inviteList ?? null)
244
+ }, timeoutMs)
245
+
246
+ let unsubscribe: () => void = () => {}
247
+ unsubscribe = this.nostrSubscribe(
248
+ {
249
+ kinds: [INVITE_LIST_EVENT_KIND],
250
+ authors: [pubkey],
251
+ "#d": ["double-ratchet/invite-list"],
252
+ },
253
+ (event) => {
254
+ if (resolved) return
255
+ try {
256
+ const inviteList = InviteList.fromEvent(event)
257
+ if (!latestEvent || event.created_at >= latestEvent.event.created_at) {
258
+ latestEvent = { event, inviteList }
259
+ }
260
+ } catch {
261
+ // Invalid event
262
+ }
263
+ }
264
+ )
265
+
266
+ if (resolved) {
267
+ unsubscribe()
268
+ }
269
+ })
270
+ }
271
+
272
+ private mergeInviteLists(local: InviteList | null, remote: InviteList | null): InviteList {
273
+ if (local && remote) return local.merge(remote)
274
+ if (local) return local
275
+ if (remote) return remote
276
+ return new InviteList(this.ownerPublicKey)
277
+ }
278
+
279
+ private async modifyInviteList(change: (list: InviteList) => void): Promise<void> {
280
+ const remote = await this.fetchInviteList(this.ownerPublicKey)
281
+ const merged = this.mergeInviteLists(this.inviteList, remote)
282
+ change(merged)
283
+
284
+ const event = merged.getEvent()
285
+ await this.nostrPublish(event)
286
+ await this.saveInviteList(merged)
287
+ this.inviteList = merged
288
+ }
289
+
290
+ private subscribeToOwnInviteList(): void {
291
+ const unsubscribe = this.nostrSubscribe(
292
+ {
293
+ kinds: [INVITE_LIST_EVENT_KIND],
294
+ authors: [this.ownerPublicKey],
295
+ "#d": ["double-ratchet/invite-list"],
296
+ },
297
+ (event) => {
298
+ try {
299
+ const remote = InviteList.fromEvent(event)
300
+ if (this.inviteList) {
301
+ this.inviteList = this.inviteList.merge(remote)
302
+ this.saveInviteList(this.inviteList).catch(console.error)
303
+ }
304
+ } catch {
305
+ // Invalid event, ignore
306
+ }
307
+ }
308
+ )
309
+
310
+ this.subscriptions.push(unsubscribe)
311
+ }
312
+ }
313
+
314
+ /** Delegate device. Has own identity key, waits for activation, checks revocation. */
315
+ export class DelegateDeviceManager implements IDeviceManager {
316
+ private readonly deviceId: string
317
+ private readonly nostrSubscribe: NostrSubscribe
318
+ private readonly nostrPublish: NostrPublish
319
+ private readonly storage: StorageAdapter
320
+
321
+ private readonly devicePublicKey: string
322
+ private readonly devicePrivateKey: Uint8Array
323
+ private readonly ephemeralPublicKey: string
324
+ private readonly ephemeralPrivateKey: Uint8Array
325
+ private readonly sharedSecret: string
326
+
327
+ private ownerPubkeyFromActivation?: string
328
+ private initialized = false
329
+ private subscriptions: Unsubscribe[] = []
330
+
331
+ private readonly storageVersion = "1"
332
+ private get versionPrefix(): string {
333
+ return `v${this.storageVersion}`
334
+ }
335
+
336
+ private constructor(
337
+ deviceId: string,
338
+ nostrSubscribe: NostrSubscribe,
339
+ nostrPublish: NostrPublish,
340
+ storage: StorageAdapter,
341
+ devicePublicKey: string,
342
+ devicePrivateKey: Uint8Array,
343
+ ephemeralPublicKey: string,
344
+ ephemeralPrivateKey: Uint8Array,
345
+ sharedSecret: string,
346
+ ) {
347
+ this.deviceId = deviceId
348
+ this.nostrSubscribe = nostrSubscribe
349
+ this.nostrPublish = nostrPublish
350
+ this.storage = storage
351
+ this.devicePublicKey = devicePublicKey
352
+ this.devicePrivateKey = devicePrivateKey
353
+ this.ephemeralPublicKey = ephemeralPublicKey
354
+ this.ephemeralPrivateKey = ephemeralPrivateKey
355
+ this.sharedSecret = sharedSecret
356
+ }
357
+
358
+ static create(options: DelegateDeviceOptions): CreateDelegateResult {
359
+ const devicePrivateKey = generateSecretKey()
360
+ const devicePublicKey = getPublicKey(devicePrivateKey)
361
+ const ephemeralPrivateKey = generateSecretKey()
362
+ const ephemeralPublicKey = getPublicKey(ephemeralPrivateKey)
363
+ const sharedSecret = bytesToHex(generateSecretKey())
364
+
365
+ const manager = new DelegateDeviceManager(
366
+ options.deviceId,
367
+ options.nostrSubscribe,
368
+ options.nostrPublish,
369
+ options.storage || new InMemoryStorageAdapter(),
370
+ devicePublicKey,
371
+ devicePrivateKey,
372
+ ephemeralPublicKey,
373
+ ephemeralPrivateKey,
374
+ sharedSecret,
375
+ )
376
+
377
+ const payload: DevicePayload = {
378
+ ephemeralPubkey: ephemeralPublicKey,
379
+ sharedSecret,
380
+ deviceId: options.deviceId,
381
+ deviceLabel: options.deviceLabel,
382
+ identityPubkey: devicePublicKey,
383
+ }
384
+
385
+ return { manager, payload }
386
+ }
387
+
388
+ static restore(options: RestoreDelegateOptions): DelegateDeviceManager {
389
+ return new DelegateDeviceManager(
390
+ options.deviceId,
391
+ options.nostrSubscribe,
392
+ options.nostrPublish,
393
+ options.storage || new InMemoryStorageAdapter(),
394
+ options.devicePublicKey,
395
+ options.devicePrivateKey,
396
+ options.ephemeralPublicKey,
397
+ options.ephemeralPrivateKey,
398
+ options.sharedSecret,
399
+ )
400
+ }
401
+
402
+ async init(): Promise<void> {
403
+ if (this.initialized) return
404
+ this.initialized = true
405
+
406
+ const storedOwnerPubkey = await this.storage.get<string>(this.ownerPubkeyKey())
407
+ if (storedOwnerPubkey) {
408
+ this.ownerPubkeyFromActivation = storedOwnerPubkey
409
+ }
410
+ }
411
+
412
+ getDeviceId(): string {
413
+ return this.deviceId
414
+ }
415
+
416
+ getIdentityPublicKey(): string {
417
+ return this.devicePublicKey
418
+ }
419
+
420
+ getIdentityKey(): Uint8Array {
421
+ return this.devicePrivateKey
422
+ }
423
+
424
+ getEphemeralKeypair(): { publicKey: string; privateKey: Uint8Array } {
425
+ return {
426
+ publicKey: this.ephemeralPublicKey,
427
+ privateKey: this.ephemeralPrivateKey,
428
+ }
429
+ }
430
+
431
+ getSharedSecret(): string {
432
+ return this.sharedSecret
433
+ }
434
+
435
+ getOwnerPublicKey(): string | null {
436
+ return this.ownerPubkeyFromActivation || null
437
+ }
438
+
439
+ async waitForActivation(timeoutMs = 60000): Promise<string> {
440
+ if (this.ownerPubkeyFromActivation) {
441
+ return this.ownerPubkeyFromActivation
442
+ }
443
+
444
+ return new Promise((resolve, reject) => {
445
+ const timeout = setTimeout(() => {
446
+ unsubscribe()
447
+ reject(new Error("Activation timeout"))
448
+ }, timeoutMs)
449
+
450
+ // Subscribe to all InviteList events and look for our deviceId
451
+ const unsubscribe = this.nostrSubscribe(
452
+ {
453
+ kinds: [INVITE_LIST_EVENT_KIND],
454
+ "#d": ["double-ratchet/invite-list"],
455
+ },
456
+ async (event) => {
457
+ try {
458
+ const inviteList = InviteList.fromEvent(event)
459
+ const device = inviteList.getDevice(this.deviceId)
460
+
461
+ if (device && device.ephemeralPublicKey === this.ephemeralPublicKey) {
462
+ clearTimeout(timeout)
463
+ unsubscribe()
464
+ this.ownerPubkeyFromActivation = event.pubkey
465
+ await this.storage.put(this.ownerPubkeyKey(), event.pubkey)
466
+ resolve(event.pubkey)
467
+ }
468
+ } catch {
469
+ // Invalid InviteList
470
+ }
471
+ }
472
+ )
473
+
474
+ this.subscriptions.push(unsubscribe)
475
+ })
476
+ }
477
+
478
+ async isRevoked(): Promise<boolean> {
479
+ const ownerPubkey = this.getOwnerPublicKey()
480
+ if (!ownerPubkey) return false
481
+
482
+ const inviteList = await this.fetchInviteList(ownerPubkey)
483
+ if (!inviteList) return true
484
+
485
+ const device = inviteList.getDevice(this.deviceId)
486
+ return !device || device.ephemeralPublicKey !== this.ephemeralPublicKey
487
+ }
488
+
489
+ close(): void {
490
+ for (const unsubscribe of this.subscriptions) {
491
+ unsubscribe()
492
+ }
493
+ this.subscriptions = []
494
+ }
495
+
496
+ createSessionManager(sessionStorage?: StorageAdapter): SessionManager {
497
+ if (!this.initialized) {
498
+ throw new Error("DeviceManager must be initialized before creating SessionManager")
499
+ }
500
+
501
+ const ownerPublicKey = this.getOwnerPublicKey()
502
+ if (!ownerPublicKey) {
503
+ throw new Error("Owner public key required for SessionManager - device must be activated first")
504
+ }
505
+
506
+ return new SessionManager(
507
+ this.devicePublicKey,
508
+ this.devicePrivateKey,
509
+ this.deviceId,
510
+ this.nostrSubscribe,
511
+ this.nostrPublish,
512
+ ownerPublicKey,
513
+ {
514
+ ephemeralKeypair: {
515
+ publicKey: this.ephemeralPublicKey,
516
+ privateKey: this.ephemeralPrivateKey,
517
+ },
518
+ sharedSecret: this.sharedSecret,
519
+ },
520
+ sessionStorage || this.storage,
521
+ )
522
+ }
523
+
524
+ private ownerPubkeyKey(): string {
525
+ return `${this.versionPrefix}/device-manager/owner-pubkey`
526
+ }
527
+
528
+ private fetchInviteList(pubkey: string, timeoutMs = 500): Promise<InviteList | null> {
529
+ return new Promise((resolve) => {
530
+ let latestEvent: { event: VerifiedEvent; inviteList: InviteList } | null = null
531
+ let resolved = false
532
+
533
+ setTimeout(() => {
534
+ if (resolved) return
535
+ resolved = true
536
+ unsubscribe()
537
+ resolve(latestEvent?.inviteList ?? null)
538
+ }, timeoutMs)
539
+
540
+ let unsubscribe: () => void = () => {}
541
+ unsubscribe = this.nostrSubscribe(
542
+ {
543
+ kinds: [INVITE_LIST_EVENT_KIND],
544
+ authors: [pubkey],
545
+ "#d": ["double-ratchet/invite-list"],
546
+ },
547
+ (event) => {
548
+ if (resolved) return
549
+ try {
550
+ const inviteList = InviteList.fromEvent(event)
551
+ if (!latestEvent || event.created_at >= latestEvent.event.created_at) {
552
+ latestEvent = { event, inviteList }
553
+ }
554
+ } catch {
555
+ // Invalid event
556
+ }
557
+ }
558
+ )
559
+
560
+ if (resolved) {
561
+ unsubscribe()
562
+ }
563
+ })
564
+ }
565
+ }