nostr-double-ratchet 0.0.32 → 0.0.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Invite.ts CHANGED
@@ -130,6 +130,10 @@ export class Invite {
130
130
  };
131
131
  const seenIds = new Set<string>()
132
132
  const unsub = subscribe(filter, (event) => {
133
+ if (event.pubkey !== user) {
134
+ console.error("Got invite event from wrong user", event.pubkey, "expected", user)
135
+ return;
136
+ }
133
137
  if (seenIds.has(event.id)) return
134
138
  seenIds.add(event.id)
135
139
  try {
@@ -189,16 +193,38 @@ export class Invite {
189
193
  };
190
194
  }
191
195
 
196
+ /**
197
+ * Creates an "invite tombstone" event that clears the original content and removes the list tag.
198
+ * Used when the inviter logs out and wants to make the invite invisible to other devices.
199
+ */
200
+ getDeletionEvent(): UnsignedEvent {
201
+ if (!this.deviceId) {
202
+ throw new Error("Device ID is required");
203
+ }
204
+ return {
205
+ kind: INVITE_EVENT_KIND,
206
+ pubkey: this.inviter,
207
+ content: "", // deliberately empty
208
+ created_at: Math.floor(Date.now() / 1000),
209
+ tags: [
210
+ ['ephemeralKey', this.inviterEphemeralPublicKey],
211
+ ['sharedSecret', this.sharedSecret],
212
+ ['d', 'double-ratchet/invites/' + this.deviceId], // same d tag
213
+ ],
214
+ };
215
+ }
216
+
192
217
  /**
193
218
  * Called by the invitee. Accepts the invite and creates a new session with the inviter.
194
219
  *
195
220
  * @param nostrSubscribe - A function to subscribe to Nostr events
196
221
  * @param inviteePublicKey - The invitee's public key
197
222
  * @param encryptor - The invitee's secret key or a signing/encrypt function
223
+ * @param deviceId - Optional device ID to identify the invitee's device
198
224
  * @returns An object containing the new session and an event to be published
199
225
  *
200
226
  * 1. Inner event: No signature, content encrypted with DH(inviter, invitee).
201
- * Purpose: Authenticate invitee. Contains invitee session key.
227
+ * Purpose: Authenticate invitee. Contains invitee session key and deviceId.
202
228
  * 2. Envelope: No signature, content encrypted with DH(inviter, random key).
203
229
  * Purpose: Contains inner event. Hides invitee from others who might have the shared Nostr key.
204
230
 
@@ -209,6 +235,7 @@ export class Invite {
209
235
  nostrSubscribe: NostrSubscribe,
210
236
  inviteePublicKey: string,
211
237
  encryptor: Uint8Array | EncryptFunction,
238
+ deviceId?: string,
212
239
  ): Promise<{ session: Session, event: VerifiedEvent }> {
213
240
  const inviteeSessionKey = generateSecretKey();
214
241
  const inviteeSessionPublicKey = getPublicKey(inviteeSessionKey);
@@ -223,7 +250,11 @@ export class Invite {
223
250
  encryptor :
224
251
  (plaintext: string, pubkey: string) => Promise.resolve(nip44.encrypt(plaintext, getConversationKey(encryptor, pubkey)));
225
252
 
226
- const dhEncrypted = await encrypt(inviteeSessionPublicKey, inviterPublicKey);
253
+ const payload = JSON.stringify({
254
+ sessionKey: inviteeSessionPublicKey,
255
+ deviceId: deviceId
256
+ });
257
+ const dhEncrypted = await encrypt(payload, inviterPublicKey);
227
258
 
228
259
  const innerEvent = {
229
260
  pubkey: inviteePublicKey,
@@ -247,7 +278,7 @@ export class Invite {
247
278
  return { session, event: finalizeEvent(envelope, randomSenderKey) };
248
279
  }
249
280
 
250
- listen(decryptor: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (_session: Session, _identity?: string) => void): Unsubscribe {
281
+ listen(decryptor: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (_session: Session, _identity: string, _deviceId?: string) => void): Unsubscribe {
251
282
  if (!this.inviterEphemeralPrivateKey) {
252
283
  throw new Error("Inviter session key is not available");
253
284
  }
@@ -279,12 +310,23 @@ export class Invite {
279
310
  decryptor :
280
311
  (ciphertext: string, pubkey: string) => Promise.resolve(nip44.decrypt(ciphertext, getConversationKey(decryptor, pubkey)));
281
312
 
282
- const inviteeSessionPublicKey = await innerDecrypt(dhEncrypted, inviteeIdentity);
313
+ const decryptedPayload = await innerDecrypt(dhEncrypted, inviteeIdentity);
314
+
315
+ let inviteeSessionPublicKey: string;
316
+ let deviceId: string | undefined;
317
+
318
+ try {
319
+ const parsed = JSON.parse(decryptedPayload);
320
+ inviteeSessionPublicKey = parsed.sessionKey;
321
+ deviceId = parsed.deviceId;
322
+ } catch {
323
+ inviteeSessionPublicKey = decryptedPayload;
324
+ }
283
325
 
284
326
  const name = event.id;
285
327
  const session = Session.init(nostrSubscribe, inviteeSessionPublicKey, this.inviterEphemeralPrivateKey!, false, sharedSecret, name);
286
328
 
287
- onSession(session, inviteeIdentity);
329
+ onSession(session, inviteeIdentity, deviceId);
288
330
  } catch {
289
331
  }
290
332
  });
package/src/UserRecord.ts CHANGED
@@ -2,16 +2,18 @@ import { Session } from './Session';
2
2
  import { NostrSubscribe } from './types';
3
3
 
4
4
  interface DeviceRecord {
5
+ deviceId: string;
5
6
  publicKey: string;
6
7
  activeSession?: Session;
7
8
  inactiveSessions: Session[];
8
9
  isStale: boolean;
9
10
  staleTimestamp?: number;
11
+ lastActivity?: number;
10
12
  }
11
13
 
12
14
  /**
13
- * WIP: Conversation management system similar to Signal's Sesame
14
- * https://signal.org/docs/specifications/sesame/
15
+ * Manages sessions for a single user across multiple devices
16
+ * Structure: UserRecord → DeviceRecord → Sessions
15
17
  */
16
18
  export class UserRecord {
17
19
  private deviceRecords: Map<string, DeviceRecord> = new Map();
@@ -19,231 +21,318 @@ export class UserRecord {
19
21
  private staleTimestamp?: number;
20
22
 
21
23
  constructor(
22
- public _userId: string,
23
- private _nostrSubscribe: NostrSubscribe,
24
+ public readonly userId: string,
25
+ private readonly nostrSubscribe: NostrSubscribe,
24
26
  ) {
25
27
  }
26
28
 
29
+ // ============================================================================
30
+ // Device Management
31
+ // ============================================================================
32
+
27
33
  /**
28
- * Adds or updates a device record for this user
34
+ * Creates or updates a device record for this user
29
35
  */
30
- public conditionalUpdate(deviceId: string, publicKey: string): void {
31
- const existingRecord = this.deviceRecords.get(deviceId);
36
+ public upsertDevice(deviceId: string, publicKey: string): DeviceRecord {
37
+ let record = this.deviceRecords.get(deviceId);
32
38
 
33
- // If device record doesn't exist or public key changed, create new empty record
34
- if (!existingRecord || existingRecord.publicKey !== publicKey) {
35
- this.deviceRecords.set(deviceId, {
39
+ if (!record) {
40
+ record = {
41
+ deviceId,
36
42
  publicKey,
37
43
  inactiveSessions: [],
38
- isStale: false
39
- });
44
+ isStale: false,
45
+ lastActivity: Date.now()
46
+ };
47
+ this.deviceRecords.set(deviceId, record);
48
+ } else if (record.publicKey !== publicKey) {
49
+ // Public key changed - mark old sessions as inactive and update
50
+ if (record.activeSession) {
51
+ record.inactiveSessions.push(record.activeSession);
52
+ record.activeSession = undefined;
53
+ }
54
+ record.publicKey = publicKey;
55
+ record.lastActivity = Date.now();
40
56
  }
57
+
58
+ return record;
41
59
  }
42
60
 
43
61
  /**
44
- * Inserts a new session for a device, making it the active session
62
+ * Gets a device record by deviceId
45
63
  */
46
- public insertSession(deviceId: string, session: Session): void {
47
- this.upsertSession(deviceId, session)
64
+ public getDevice(deviceId: string): DeviceRecord | undefined {
65
+ return this.deviceRecords.get(deviceId);
66
+ }
67
+
68
+ /**
69
+ * Gets all device records for this user
70
+ */
71
+ public getAllDevices(): DeviceRecord[] {
72
+ return Array.from(this.deviceRecords.values());
73
+ }
74
+
75
+ /**
76
+ * Gets all active (non-stale) device records
77
+ */
78
+ public getActiveDevices(): DeviceRecord[] {
79
+ if (this.isStale) return [];
80
+ return Array.from(this.deviceRecords.values()).filter(device => !device.isStale);
48
81
  }
49
82
 
50
83
  /**
51
- * Activates an inactive session for a device
84
+ * Removes a device record and closes all its sessions
52
85
  */
53
- public activateSession(deviceId: string, session: Session): void {
86
+ public removeDevice(deviceId: string): boolean {
54
87
  const record = this.deviceRecords.get(deviceId);
55
- if (!record) {
56
- throw new Error(`No device record found for ${deviceId}`);
57
- }
88
+ if (!record) return false;
58
89
 
59
- const sessionIndex = record.inactiveSessions.indexOf(session);
60
- if (sessionIndex === -1) {
61
- throw new Error('Session not found in inactive sessions');
62
- }
90
+ // Close all sessions for this device
91
+ record.activeSession?.close();
92
+ record.inactiveSessions.forEach(session => session.close());
93
+
94
+ return this.deviceRecords.delete(deviceId);
95
+ }
63
96
 
64
- // Remove session from inactive list
65
- record.inactiveSessions.splice(sessionIndex, 1);
97
+ // ============================================================================
98
+ // Session Management
99
+ // ============================================================================
66
100
 
67
- // Move current active session to inactive list if it exists
68
- if (record.activeSession) {
69
- record.inactiveSessions.unshift(record.activeSession);
101
+ /**
102
+ * Adds or updates a session for a specific device
103
+ */
104
+ public upsertSession(deviceId: string, session: Session, publicKey?: string): void {
105
+ const device = this.upsertDevice(deviceId, publicKey || session.state?.theirNextNostrPublicKey || '');
106
+
107
+ // If there's an active session, move it to inactive
108
+ if (device.activeSession) {
109
+ device.inactiveSessions.unshift(device.activeSession);
70
110
  }
71
111
 
72
- // Set selected session as active
73
- record.activeSession = session;
112
+ // Set the new session as active
113
+ session.name = deviceId; // Ensure session name matches deviceId
114
+ device.activeSession = session;
115
+ device.lastActivity = Date.now();
74
116
  }
75
117
 
76
118
  /**
77
- * Marks a device record as stale
119
+ * Gets the active session for a specific device
78
120
  */
79
- public markDeviceStale(deviceId: string): void {
80
- const record = this.deviceRecords.get(deviceId);
81
- if (record) {
82
- record.isStale = true;
83
- record.staleTimestamp = Date.now();
84
- }
121
+ public getActiveSession(deviceId: string): Session | undefined {
122
+ const device = this.deviceRecords.get(deviceId);
123
+ return device?.isStale ? undefined : device?.activeSession;
85
124
  }
86
125
 
87
126
  /**
88
- * Marks the entire user record as stale
127
+ * Gets all sessions (active + inactive) for a specific device
89
128
  */
90
- public markUserStale(): void {
91
- this.isStale = true;
92
- this.staleTimestamp = Date.now();
129
+ public getDeviceSessions(deviceId: string): Session[] {
130
+ const device = this.deviceRecords.get(deviceId);
131
+ if (!device) return [];
132
+
133
+ const sessions: Session[] = [];
134
+ if (device.activeSession) {
135
+ sessions.push(device.activeSession);
136
+ }
137
+ sessions.push(...device.inactiveSessions);
138
+ return sessions;
93
139
  }
94
140
 
95
141
  /**
96
- * Gets all non-stale device records with active sessions
142
+ * Gets all active sessions across all devices for this user
97
143
  */
98
- public getActiveDevices(): Array<[string, Session]> {
99
- if (this.isStale) return [];
144
+ public getActiveSessions(): Session[] {
145
+ const sessions: Session[] = [];
146
+
147
+ for (const device of this.getActiveDevices()) {
148
+ if (device.activeSession) {
149
+ sessions.push(device.activeSession);
150
+ }
151
+ }
152
+
153
+ // Sort by ability to send messages (prioritize ready sessions)
154
+ sessions.sort((a, b) => {
155
+ const aCanSend = !!(a.state?.theirNextNostrPublicKey && a.state?.ourCurrentNostrKey);
156
+ const bCanSend = !!(b.state?.theirNextNostrPublicKey && b.state?.ourCurrentNostrKey);
157
+
158
+ if (aCanSend && !bCanSend) return -1;
159
+ if (!aCanSend && bCanSend) return 1;
160
+ return 0;
161
+ });
100
162
 
101
- return Array.from(this.deviceRecords.entries())
102
- .filter(([, record]) => !record.isStale && record.activeSession)
103
- .map(([deviceId, record]) => [deviceId, record.activeSession!]);
163
+ return sessions;
104
164
  }
105
165
 
106
166
  /**
107
- * Creates a new session for a device
167
+ * Gets all sessions (active + inactive) across all devices
108
168
  */
109
- public createSession(
110
- deviceId: string,
111
- sharedSecret: Uint8Array,
112
- ourCurrentPrivateKey: Uint8Array,
113
- isInitiator: boolean,
114
- name?: string
115
- ): Session {
116
- const record = this.deviceRecords.get(deviceId);
117
- if (!record) {
118
- throw new Error(`No device record found for ${deviceId}`);
169
+ public getAllSessions(): Session[] {
170
+ const sessions: Session[] = [];
171
+
172
+ for (const device of this.deviceRecords.values()) {
173
+ if (device.activeSession) {
174
+ sessions.push(device.activeSession);
175
+ }
176
+ sessions.push(...device.inactiveSessions);
119
177
  }
120
178
 
121
- const session = Session.init(
122
- this._nostrSubscribe,
123
- record.publicKey,
124
- ourCurrentPrivateKey,
125
- isInitiator,
126
- sharedSecret,
127
- name
179
+ return sessions;
180
+ }
181
+
182
+ /**
183
+ * Gets sessions that are ready to send messages
184
+ */
185
+ public getSendableSessions(): Session[] {
186
+ return this.getActiveSessions().filter(session =>
187
+ !!(session.state?.theirNextNostrPublicKey && session.state?.ourCurrentNostrKey)
128
188
  );
189
+ }
129
190
 
130
- this.insertSession(deviceId, session);
131
- return session;
191
+ // ============================================================================
192
+ // Stale Management
193
+ // ============================================================================
194
+
195
+ /**
196
+ * Marks a specific device as stale
197
+ */
198
+ public markDeviceStale(deviceId: string): void {
199
+ const device = this.deviceRecords.get(deviceId);
200
+ if (device) {
201
+ device.isStale = true;
202
+ device.staleTimestamp = Date.now();
203
+ }
132
204
  }
133
205
 
134
206
  /**
135
- * Deletes stale records that are older than maxLatency
207
+ * Marks the entire user record as stale
208
+ */
209
+ public markUserStale(): void {
210
+ this.isStale = true;
211
+ this.staleTimestamp = Date.now();
212
+ }
213
+
214
+ /**
215
+ * Removes stale devices and sessions older than maxLatency
136
216
  */
137
217
  public pruneStaleRecords(maxLatency: number): void {
138
218
  const now = Date.now();
139
219
 
140
- // Delete stale device records
141
- for (const [deviceId, record] of this.deviceRecords.entries()) {
142
- if (record.isStale && record.staleTimestamp &&
143
- (now - record.staleTimestamp) > maxLatency) {
144
- // Close all sessions
145
- record.activeSession?.close();
146
- record.inactiveSessions.forEach(session => session.close());
147
- this.deviceRecords.delete(deviceId);
220
+ // Remove stale devices
221
+ for (const [deviceId, device] of this.deviceRecords.entries()) {
222
+ if (device.isStale && device.staleTimestamp &&
223
+ (now - device.staleTimestamp) > maxLatency) {
224
+ this.removeDevice(deviceId);
148
225
  }
149
226
  }
150
227
 
151
- // Delete entire user record if stale
228
+ // Remove entire user record if stale
152
229
  if (this.isStale && this.staleTimestamp &&
153
230
  (now - this.staleTimestamp) > maxLatency) {
154
- this.deviceRecords.forEach(record => {
155
- record.activeSession?.close();
156
- record.inactiveSessions.forEach(session => session.close());
157
- });
158
- this.deviceRecords.clear();
231
+ this.close();
159
232
  }
160
233
  }
161
234
 
235
+ // ============================================================================
236
+ // Utility Methods
237
+ // ============================================================================
238
+
162
239
  /**
163
- * Cleanup when destroying the user record
240
+ * Gets the most recently active device
164
241
  */
165
- public close(): void {
166
- this.deviceRecords.forEach(record => {
167
- record.activeSession?.close();
168
- record.inactiveSessions.forEach(session => session.close());
242
+ public getMostActiveDevice(): DeviceRecord | undefined {
243
+ const activeDevices = this.getActiveDevices();
244
+ if (activeDevices.length === 0) return undefined;
245
+
246
+ return activeDevices.reduce((most, current) => {
247
+ const mostActivity = most.lastActivity || 0;
248
+ const currentActivity = current.lastActivity || 0;
249
+ return currentActivity > mostActivity ? current : most;
169
250
  });
170
- this.deviceRecords.clear();
171
251
  }
172
252
 
173
- // ---------------------------------------------------------------------------
174
- // Helper methods used by SessionManager (WIP):
175
- // ---------------------------------------------------------------------------
176
-
177
253
  /**
178
- * Return all sessions that are currently considered *active*.
179
- * For now this means any session in a non-stale device record as well as
180
- * all sessions added through `addSession`.
181
- * Prioritizes initiator sessions (can send first message) over responder sessions.
254
+ * Gets the preferred session (from most active device)
182
255
  */
183
- public getActiveSessions(): Session[] {
184
- const sessions: Session[] = [];
185
-
186
- for (const record of this.deviceRecords.values()) {
187
- if (!record.isStale && record.activeSession) {
188
- sessions.push(record.activeSession);
189
- }
190
- }
256
+ public getPreferredSession(): Session | undefined {
257
+ const mostActive = this.getMostActiveDevice();
258
+ return mostActive?.activeSession;
259
+ }
191
260
 
192
- sessions.sort((a, b) => {
193
- const aCanSend = !!(a.state?.theirNextNostrPublicKey && a.state?.ourCurrentNostrKey);
194
- const bCanSend = !!(b.state?.theirNextNostrPublicKey && b.state?.ourCurrentNostrKey);
195
-
196
- if (aCanSend && !bCanSend) return -1; // a comes first
197
- if (!aCanSend && bCanSend) return 1; // b comes first
198
- return 0; // equal priority
199
- });
261
+ /**
262
+ * Checks if this user has any active sessions
263
+ */
264
+ public hasActiveSessions(): boolean {
265
+ return this.getActiveSessions().length > 0;
266
+ }
200
267
 
201
- return sessions;
268
+ /**
269
+ * Gets total count of devices
270
+ */
271
+ public getDeviceCount(): number {
272
+ return this.deviceRecords.size;
202
273
  }
203
274
 
204
275
  /**
205
- * Return *all* sessions active or inactive — that we have stored for this
206
- * user. This is required for `SessionManager.onEvent` so that it can attach
207
- * listeners to existing sessions.
276
+ * Gets total count of active sessions
208
277
  */
209
- public getAllSessions(): Session[] {
210
- const sessions: Session[] = [];
278
+ public getActiveSessionCount(): number {
279
+ return this.getActiveSessions().length;
280
+ }
211
281
 
212
- for (const record of this.deviceRecords.values()) {
213
- if (record.activeSession) {
214
- sessions.push(record.activeSession);
215
- }
216
- sessions.push(...record.inactiveSessions);
282
+ /**
283
+ * Cleanup when destroying the user record
284
+ */
285
+ public close(): void {
286
+ for (const device of this.deviceRecords.values()) {
287
+ device.activeSession?.close();
288
+ device.inactiveSessions.forEach(session => session.close());
217
289
  }
290
+ this.deviceRecords.clear();
291
+ }
218
292
 
219
- return sessions;
293
+ // ============================================================================
294
+ // Legacy Compatibility Methods
295
+ // ============================================================================
296
+
297
+ /**
298
+ * @deprecated Use upsertDevice instead
299
+ */
300
+ public conditionalUpdate(deviceId: string, publicKey: string): void {
301
+ this.upsertDevice(deviceId, publicKey);
220
302
  }
221
303
 
222
304
  /**
223
- * Unified helper that either associates the session with a device record
224
- * (if deviceId provided **and** the record exists) or falls back to the
225
- * legacy extraSessions list.
305
+ * @deprecated Use upsertSession instead
226
306
  */
227
- public upsertSession(deviceId: string | undefined, session: Session) {
228
- if (!deviceId) {
229
- deviceId = 'unknown'
230
- }
307
+ public insertSession(deviceId: string, session: Session): void {
308
+ this.upsertSession(deviceId, session);
309
+ }
231
310
 
232
- let record = this.deviceRecords.get(deviceId)
233
- if (!record) {
234
- record = {
235
- publicKey: session.state?.theirNextNostrPublicKey || '',
236
- inactiveSessions: [],
237
- isStale: false
238
- }
239
- this.deviceRecords.set(deviceId, record)
311
+ /**
312
+ * Creates a new session for a device
313
+ */
314
+ public createSession(
315
+ deviceId: string,
316
+ sharedSecret: Uint8Array,
317
+ ourCurrentPrivateKey: Uint8Array,
318
+ isInitiator: boolean,
319
+ name?: string
320
+ ): Session {
321
+ const device = this.getDevice(deviceId);
322
+ if (!device) {
323
+ throw new Error(`No device record found for ${deviceId}`);
240
324
  }
241
325
 
242
- if (record.activeSession) {
243
- record.inactiveSessions.unshift(record.activeSession)
244
- }
245
- // Ensure session name matches deviceId for easier identification
246
- session.name = deviceId
247
- record.activeSession = session
326
+ const session = Session.init(
327
+ this.nostrSubscribe,
328
+ device.publicKey,
329
+ ourCurrentPrivateKey,
330
+ isInitiator,
331
+ sharedSecret,
332
+ name || deviceId
333
+ );
334
+
335
+ this.upsertSession(deviceId, session);
336
+ return session;
248
337
  }
249
338
  }