nostr-double-ratchet 0.0.38 → 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
@@ -200,7 +200,6 @@ export class Invite {
200
200
  /**
201
201
  * Creates a tombstone event that replaces the invite, signaling device revocation.
202
202
  * The tombstone has the same d-tag but no keys, making it invalid as an invite.
203
- * Used during migration to InviteList or when revoking a device.
204
203
  */
205
204
  getDeletionEvent(): UnsignedEvent {
206
205
  if (!this.deviceId) {
@@ -222,13 +221,13 @@ export class Invite {
222
221
  * Called by the invitee. Accepts the invite and creates a new session with the inviter.
223
222
  *
224
223
  * @param nostrSubscribe - A function to subscribe to Nostr events
225
- * @param inviteePublicKey - The invitee's public key
224
+ * @param inviteePublicKey - The invitee's identity public key (also serves as device ID)
226
225
  * @param encryptor - The invitee's secret key or a signing/encrypt function
227
- * @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)
228
227
  * @returns An object containing the new session and an event to be published
229
228
  *
230
229
  * 1. Inner event: No signature, content encrypted with DH(inviter, invitee).
231
- * Purpose: Authenticate invitee. Contains invitee session key and deviceId.
230
+ * Purpose: Authenticate invitee. Contains invitee session key and ownerPublicKey.
232
231
  * 2. Envelope: No signature, content encrypted with DH(inviter, random key).
233
232
  * Purpose: Contains inner event. Hides invitee from others who might have the shared Nostr key.
234
233
 
@@ -239,7 +238,7 @@ export class Invite {
239
238
  nostrSubscribe: NostrSubscribe,
240
239
  inviteePublicKey: string,
241
240
  encryptor: Uint8Array | EncryptFunction,
242
- deviceId?: string,
241
+ ownerPublicKey?: string,
243
242
  ): Promise<{ session: Session, event: VerifiedEvent }> {
244
243
  const inviteeSessionKeypair = generateEphemeralKeypair();
245
244
  const inviterPublicKey = this.inviter || this.inviterEphemeralPublicKey;
@@ -262,14 +261,14 @@ export class Invite {
262
261
  inviterPublicKey,
263
262
  inviterEphemeralPublicKey: this.inviterEphemeralPublicKey,
264
263
  sharedSecret: this.sharedSecret,
265
- deviceId,
264
+ ownerPublicKey,
266
265
  encrypt,
267
266
  });
268
267
 
269
268
  return { session, event: finalizeEvent(encrypted.envelope, encrypted.randomSenderPrivateKey) };
270
269
  }
271
270
 
272
- 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 {
273
272
  if (!this.inviterEphemeralPrivateKey) {
274
273
  throw new Error("Inviter session key is not available");
275
274
  }
@@ -308,8 +307,10 @@ export class Invite {
308
307
  name: event.id,
309
308
  });
310
309
 
311
- onSession(session, decrypted.inviteeIdentity, decrypted.deviceId);
310
+ // inviteeIdentity serves as both identity and device ID
311
+ onSession(session, decrypted.inviteeIdentity);
312
312
  } catch {
313
+ // Failed to process invite response
313
314
  }
314
315
  });
315
316
  }
package/src/Session.ts CHANGED
@@ -9,8 +9,11 @@ import {
9
9
  MESSAGE_EVENT_KIND,
10
10
  Rumor,
11
11
  CHAT_MESSAGE_KIND,
12
+ REACTION_KIND,
13
+ RECEIPT_KIND,
14
+ TYPING_KIND,
12
15
  } from "./types";
13
- import { kdf, deepCopyState } from "./utils";
16
+ import { kdf, deepCopyState, createReactionPayload } from "./utils";
14
17
 
15
18
  const MAX_SKIP = 1000;
16
19
 
@@ -93,7 +96,7 @@ export class Session {
93
96
  previousSendingChainMessageCount: 0,
94
97
  skippedKeys: {},
95
98
  };
96
-
99
+
97
100
  const session = new Session(nostrSubscribe, state);
98
101
  if (name) session.name = name;
99
102
  return session;
@@ -113,6 +116,46 @@ export class Session {
113
116
  });
114
117
  }
115
118
 
119
+ /**
120
+ * Sends a reaction to a message through the encrypted session.
121
+ * @param messageId The ID of the message being reacted to
122
+ * @param emoji The emoji or reaction content (e.g., "👍", "❤️", "+1")
123
+ * @returns A verified Nostr event containing the encrypted reaction. You need to publish this event to the Nostr network.
124
+ * @throws Error if we are not the initiator and trying to send the first message
125
+ */
126
+ sendReaction(messageId: string, emoji: string): {event: VerifiedEvent, innerEvent: Rumor} {
127
+ return this.sendEvent({
128
+ content: createReactionPayload(messageId, emoji),
129
+ kind: REACTION_KIND,
130
+ tags: [["e", messageId]]
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Sends a typing indicator through the encrypted session.
136
+ * @returns A verified Nostr event containing the encrypted typing indicator. You need to publish this event to the Nostr network.
137
+ */
138
+ sendTyping(): {event: VerifiedEvent, innerEvent: Rumor} {
139
+ return this.sendEvent({
140
+ content: 'typing',
141
+ kind: TYPING_KIND,
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Sends a delivery/read receipt through the encrypted session.
147
+ * @param receiptType Either "delivered" or "seen"
148
+ * @param messageIds The IDs of the messages being acknowledged
149
+ * @returns A verified Nostr event containing the encrypted receipt. You need to publish this event to the Nostr network.
150
+ */
151
+ sendReceipt(receiptType: 'delivered' | 'seen', messageIds: string[]): {event: VerifiedEvent, innerEvent: Rumor} {
152
+ return this.sendEvent({
153
+ content: receiptType,
154
+ kind: RECEIPT_KIND,
155
+ tags: messageIds.map(id => ["e", id]),
156
+ });
157
+ }
158
+
116
159
  /**
117
160
  * Send a partial Nostr event through the encrypted session.
118
161
  * In addition to chat messages, it could be files, webrtc negotiation or many other types of messages.
@@ -401,7 +444,7 @@ export class Session {
401
444
  this.skippedSubscription = this.nostrSubscribe(
402
445
  {authors: skippedAuthors, kinds: [MESSAGE_EVENT_KIND]},
403
446
  (e) => this.handleNostrEvent(e)
404
- );
447
+ );
405
448
  }
406
449
  }
407
450
  }