nostr-double-ratchet 0.0.12 → 0.0.14

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
@@ -1,9 +1,7 @@
1
1
  import { generateSecretKey, getPublicKey, nip44, finalizeEvent, VerifiedEvent, UnsignedEvent, verifyEvent, Filter } from "nostr-tools";
2
- import { INVITE_EVENT_KIND, NostrSubscribe, Unsubscribe } from "./types";
2
+ import { INVITE_EVENT_KIND, NostrSubscribe, Unsubscribe, MESSAGE_EVENT_KIND, EncryptFunction, DecryptFunction } from "./types";
3
3
  import { getConversationKey } from "nostr-tools/nip44";
4
4
  import { Session } from "./Session.ts";
5
- import { MESSAGE_EVENT_KIND } from "./types";
6
- import { EncryptFunction, DecryptFunction } from "./types";
7
5
  import { hexToBytes, bytesToHex } from "@noble/hashes/utils";
8
6
 
9
7
  /**
@@ -18,10 +16,10 @@ import { hexToBytes, bytesToHex } from "@noble/hashes/utils";
18
16
  */
19
17
  export class Invite {
20
18
  constructor(
21
- public inviterSessionPublicKey: string,
22
- public linkSecret: string,
19
+ public inviterEphemeralPublicKey: string,
20
+ public sharedSecret: string,
23
21
  public inviter: string,
24
- public inviterSessionPrivateKey?: Uint8Array,
22
+ public inviterEphemeralPrivateKey?: Uint8Array,
25
23
  public label?: string,
26
24
  public maxUses?: number,
27
25
  public usedBy: string[] = [],
@@ -31,14 +29,14 @@ export class Invite {
31
29
  if (!inviter) {
32
30
  throw new Error("Inviter public key is required");
33
31
  }
34
- const inviterSessionPrivateKey = generateSecretKey();
35
- const inviterSessionPublicKey = getPublicKey(inviterSessionPrivateKey);
36
- const linkSecret = bytesToHex(generateSecretKey());
32
+ const inviterEphemeralPrivateKey = generateSecretKey();
33
+ const inviterEphemeralPublicKey = getPublicKey(inviterEphemeralPrivateKey);
34
+ const sharedSecret = bytesToHex(generateSecretKey());
37
35
  return new Invite(
38
- inviterSessionPublicKey,
39
- linkSecret,
36
+ inviterEphemeralPublicKey,
37
+ sharedSecret,
40
38
  inviter,
41
- inviterSessionPrivateKey,
39
+ inviterEphemeralPrivateKey,
42
40
  label,
43
41
  maxUses
44
42
  );
@@ -59,14 +57,14 @@ export class Invite {
59
57
  throw new Error("Invite data in URL hash is not valid JSON: " + err);
60
58
  }
61
59
 
62
- const { inviter, sessionKey, linkSecret } = data;
63
- if (!inviter || !sessionKey || !linkSecret) {
64
- throw new Error("Missing required fields (inviter, sessionKey, linkSecret) in invite data.");
60
+ const { inviter, ephemeralKey, sharedSecret } = data;
61
+ if (!inviter || !ephemeralKey || !sharedSecret) {
62
+ throw new Error("Missing required fields (inviter, ephemeralKey, sharedSecret) in invite data.");
65
63
  }
66
64
 
67
65
  return new Invite(
68
- sessionKey,
69
- linkSecret,
66
+ ephemeralKey,
67
+ sharedSecret,
70
68
  inviter
71
69
  );
72
70
  }
@@ -74,10 +72,10 @@ export class Invite {
74
72
  static deserialize(json: string): Invite {
75
73
  const data = JSON.parse(json);
76
74
  return new Invite(
77
- data.inviterSessionPublicKey,
78
- data.linkSecret,
75
+ data.inviterEphemeralPublicKey,
76
+ data.sharedSecret,
79
77
  data.inviter,
80
- data.inviterSessionPrivateKey ? new Uint8Array(data.inviterSessionPrivateKey) : undefined,
78
+ data.inviterEphemeralPrivateKey ? new Uint8Array(data.inviterEphemeralPrivateKey) : undefined,
81
79
  data.label,
82
80
  data.maxUses,
83
81
  data.usedBy
@@ -92,17 +90,17 @@ export class Invite {
92
90
  throw new Error("Event signature is invalid");
93
91
  }
94
92
  const { tags } = event;
95
- const inviterSessionPublicKey = tags.find(([key]) => key === 'sessionKey')?.[1];
96
- const linkSecret = tags.find(([key]) => key === 'linkSecret')?.[1];
93
+ const inviterEphemeralPublicKey = tags.find(([key]) => key === 'ephemeralKey')?.[1];
94
+ const sharedSecret = tags.find(([key]) => key === 'sharedSecret')?.[1];
97
95
  const inviter = event.pubkey;
98
96
 
99
- if (!inviterSessionPublicKey || !linkSecret) {
100
- throw new Error("Invalid invite event: missing session key or link secret");
97
+ if (!inviterEphemeralPublicKey || !sharedSecret) {
98
+ throw new Error("Invalid invite event: missing session key or sharedSecret");
101
99
  }
102
100
 
103
101
  return new Invite(
104
- inviterSessionPublicKey,
105
- linkSecret,
102
+ inviterEphemeralPublicKey,
103
+ sharedSecret,
106
104
  inviter
107
105
  );
108
106
  }
@@ -136,10 +134,10 @@ export class Invite {
136
134
  */
137
135
  serialize(): string {
138
136
  return JSON.stringify({
139
- inviterSessionPublicKey: this.inviterSessionPublicKey,
140
- linkSecret: this.linkSecret,
137
+ inviterEphemeralPublicKey: this.inviterEphemeralPublicKey,
138
+ sharedSecret: this.sharedSecret,
141
139
  inviter: this.inviter,
142
- inviterSessionPrivateKey: this.inviterSessionPrivateKey ? Array.from(this.inviterSessionPrivateKey) : undefined,
140
+ inviterEphemeralPrivateKey: this.inviterEphemeralPrivateKey ? Array.from(this.inviterEphemeralPrivateKey) : undefined,
143
141
  label: this.label,
144
142
  maxUses: this.maxUses,
145
143
  usedBy: this.usedBy,
@@ -152,12 +150,11 @@ export class Invite {
152
150
  getUrl(root = "https://iris.to") {
153
151
  const data = {
154
152
  inviter: this.inviter,
155
- sessionKey: this.inviterSessionPublicKey,
156
- linkSecret: this.linkSecret
153
+ ephemeralKey: this.inviterEphemeralPublicKey,
154
+ sharedSecret: this.sharedSecret
157
155
  };
158
156
  const url = new URL(root);
159
157
  url.hash = encodeURIComponent(JSON.stringify(data));
160
- console.log('url', url.toString())
161
158
  return url.toString();
162
159
  }
163
160
 
@@ -167,7 +164,7 @@ export class Invite {
167
164
  pubkey: this.inviter,
168
165
  content: "",
169
166
  created_at: Math.floor(Date.now() / 1000),
170
- tags: [['sessionKey', this.inviterSessionPublicKey], ['linkSecret', this.linkSecret], ['d', 'nostr-double-ratchet/invite']],
167
+ tags: [['ephemeralKey', this.inviterEphemeralPublicKey], ['sharedSecret', this.sharedSecret], ['d', 'nostr-double-ratchet/invite']],
171
168
  };
172
169
  }
173
170
 
@@ -189,49 +186,53 @@ export class Invite {
189
186
  async accept(
190
187
  nostrSubscribe: NostrSubscribe,
191
188
  inviteePublicKey: string,
192
- inviteeSecretKey: Uint8Array | EncryptFunction,
189
+ encryptor: Uint8Array | EncryptFunction,
193
190
  ): Promise<{ session: Session, event: VerifiedEvent }> {
194
191
  const inviteeSessionKey = generateSecretKey();
195
192
  const inviteeSessionPublicKey = getPublicKey(inviteeSessionKey);
196
- const inviterPublicKey = this.inviter || this.inviterSessionPublicKey;
193
+ const inviterPublicKey = this.inviter || this.inviterEphemeralPublicKey;
197
194
 
198
- const sharedSecret = hexToBytes(this.linkSecret);
199
- const session = Session.init(nostrSubscribe, this.inviterSessionPublicKey, inviteeSessionKey, true, sharedSecret, undefined);
195
+ const sharedSecret = hexToBytes(this.sharedSecret);
196
+ const session = Session.init(nostrSubscribe, this.inviterEphemeralPublicKey, inviteeSessionKey, true, sharedSecret, undefined);
200
197
 
201
198
  // Create a random keypair for the envelope sender
202
199
  const randomSenderKey = generateSecretKey();
203
200
  const randomSenderPublicKey = getPublicKey(randomSenderKey);
204
201
 
205
- const encrypt = typeof inviteeSecretKey === 'function' ?
206
- inviteeSecretKey :
207
- (plaintext: string, pubkey: string) => Promise.resolve(nip44.encrypt(plaintext, getConversationKey(inviteeSecretKey, pubkey)));
202
+ // should we take only Encrypt / Decrypt functions, not keys, to make it simpler and with less imports here?
203
+ // common implementation problem: plaintext, pubkey params in different order
204
+ const encrypt = typeof encryptor === 'function' ?
205
+ encryptor :
206
+ (plaintext: string, pubkey: string) => Promise.resolve(nip44.encrypt(plaintext, getConversationKey(encryptor, pubkey)));
207
+
208
+ const dhEncrypted = await encrypt(inviteeSessionPublicKey, inviterPublicKey);
208
209
 
209
210
  const innerEvent = {
210
211
  pubkey: inviteePublicKey,
211
- tags: [['secret', this.linkSecret]],
212
- content: await encrypt(inviteeSessionPublicKey, inviterPublicKey),
212
+ tags: [['sharedSecret', this.sharedSecret]],
213
+ content: await nip44.encrypt(dhEncrypted, sharedSecret),
213
214
  created_at: Math.floor(Date.now() / 1000),
214
215
  };
215
216
 
216
217
  const envelope = {
217
218
  kind: MESSAGE_EVENT_KIND,
218
219
  pubkey: randomSenderPublicKey,
219
- content: nip44.encrypt(JSON.stringify(innerEvent), getConversationKey(randomSenderKey, this.inviterSessionPublicKey)),
220
+ content: nip44.encrypt(JSON.stringify(innerEvent), getConversationKey(randomSenderKey, this.inviterEphemeralPublicKey)),
220
221
  created_at: Math.floor(Date.now() / 1000),
221
- tags: [['p', this.inviterSessionPublicKey]],
222
+ tags: [['p', this.inviterEphemeralPublicKey]],
222
223
  };
223
224
 
224
225
  return { session, event: finalizeEvent(envelope, randomSenderKey) };
225
226
  }
226
227
 
227
- listen(inviterSecretKey: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (session: Session, identity?: string) => void): Unsubscribe {
228
- if (!this.inviterSessionPrivateKey) {
228
+ listen(decryptor: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (session: Session, identity?: string) => void): Unsubscribe {
229
+ if (!this.inviterEphemeralPrivateKey) {
229
230
  throw new Error("Inviter session key is not available");
230
231
  }
231
232
 
232
233
  const filter = {
233
234
  kinds: [MESSAGE_EVENT_KIND],
234
- '#p': [this.inviterSessionPublicKey],
235
+ '#p': [this.inviterEphemeralPublicKey],
235
236
  };
236
237
 
237
238
  return nostrSubscribe(filter, async (event) => {
@@ -241,26 +242,31 @@ export class Invite {
241
242
  return;
242
243
  }
243
244
 
244
- const decrypted = await nip44.decrypt(event.content, getConversationKey(this.inviterSessionPrivateKey!, event.pubkey));
245
+ // Decrypt the outer envelope first
246
+ const decrypted = await nip44.decrypt(event.content, getConversationKey(this.inviterEphemeralPrivateKey!, event.pubkey));
245
247
  const innerEvent = JSON.parse(decrypted);
246
248
 
247
- if (!innerEvent.tags || !innerEvent.tags.some(([key, value]: [string, string]) => key === 'secret' && value === this.linkSecret)) {
249
+ if (!innerEvent.tags || !innerEvent.tags.some(([key, value]: [string, string]) => key === 'sharedSecret' && value === this.sharedSecret)) {
248
250
  console.error("Invalid secret from event", event);
249
251
  return;
250
252
  }
251
253
 
252
- const innerDecrypt = typeof inviterSecretKey === 'function' ?
253
- inviterSecretKey :
254
- (ciphertext: string, pubkey: string) => Promise.resolve(nip44.decrypt(ciphertext, getConversationKey(inviterSecretKey, pubkey)));
255
-
254
+ const sharedSecret = hexToBytes(this.sharedSecret);
256
255
  const inviteeIdentity = innerEvent.pubkey;
257
256
  this.usedBy.push(inviteeIdentity);
258
257
 
259
- const inviteeSessionPublicKey = await innerDecrypt(innerEvent.content, inviteeIdentity);
260
- const sharedSecret = hexToBytes(this.linkSecret);
258
+ // Decrypt the inner content using shared secret first
259
+ const dhEncrypted = await nip44.decrypt(innerEvent.content, sharedSecret);
260
+
261
+ // Then decrypt using DH key
262
+ const innerDecrypt = typeof decryptor === 'function' ?
263
+ decryptor :
264
+ (ciphertext: string, pubkey: string) => Promise.resolve(nip44.decrypt(ciphertext, getConversationKey(decryptor, pubkey)));
265
+
266
+ const inviteeSessionPublicKey = await innerDecrypt(dhEncrypted, inviteeIdentity);
261
267
 
262
268
  const name = event.id;
263
- const session = Session.init(nostrSubscribe, inviteeSessionPublicKey, this.inviterSessionPrivateKey!, false, sharedSecret, name);
269
+ const session = Session.init(nostrSubscribe, inviteeSessionPublicKey, this.inviterEphemeralPrivateKey!, false, sharedSecret, name);
264
270
 
265
271
  onSession(session, inviteeIdentity);
266
272
  } catch (error) {
package/src/Session.ts CHANGED
@@ -33,16 +33,16 @@ export class Session {
33
33
  /**
34
34
  * Initializes a new secure communication session
35
35
  * @param nostrSubscribe Function to subscribe to Nostr events
36
- * @param theirNostrPublicKey The public key of the other party
36
+ * @param theirNextNostrPublicKey The public key of the other party
37
37
  * @param ourCurrentPrivateKey Our current private key for Nostr
38
38
  * @param isInitiator Whether we are initiating the conversation (true) or responding (false)
39
39
  * @param sharedSecret Initial shared secret for securing the first message chain
40
40
  * @param name Optional name for the session (for debugging)
41
41
  * @returns A new Session instance
42
42
  */
43
- static init(nostrSubscribe: NostrSubscribe, theirNostrPublicKey: string, ourCurrentPrivateKey: Uint8Array, isInitiator: boolean, sharedSecret: Uint8Array, name?: string): Session {
43
+ static init(nostrSubscribe: NostrSubscribe, theirNextNostrPublicKey: string, ourCurrentPrivateKey: Uint8Array, isInitiator: boolean, sharedSecret: Uint8Array, name?: string): Session {
44
44
  const ourNextPrivateKey = generateSecretKey();
45
- const [rootKey, sendingChainKey] = kdf(sharedSecret, nip44.getConversationKey(ourNextPrivateKey, theirNostrPublicKey), 2);
45
+ const [rootKey, sendingChainKey] = kdf(sharedSecret, nip44.getConversationKey(ourNextPrivateKey, theirNextNostrPublicKey), 2);
46
46
  let ourCurrentNostrKey;
47
47
  let ourNextNostrKey;
48
48
  if (isInitiator) {
@@ -53,7 +53,7 @@ export class Session {
53
53
  }
54
54
  const state: SessionState = {
55
55
  rootKey: isInitiator ? rootKey : sharedSecret,
56
- theirNostrPublicKey,
56
+ theirNextNostrPublicKey,
57
57
  ourCurrentNostrKey,
58
58
  ourNextNostrKey,
59
59
  receivingChainKey: undefined,
@@ -76,13 +76,13 @@ export class Session {
76
76
  * @throws Error if we are not the initiator and trying to send the first message
77
77
  */
78
78
  send(data: string): VerifiedEvent {
79
- if (!this.state.theirNostrPublicKey || !this.state.ourCurrentNostrKey) {
79
+ if (!this.state.theirNextNostrPublicKey || !this.state.ourCurrentNostrKey) {
80
80
  throw new Error("we are not the initiator, so we can't send the first message");
81
81
  }
82
82
 
83
83
  const [header, encryptedData] = this.ratchetEncrypt(data);
84
84
 
85
- const sharedSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, this.state.theirNostrPublicKey);
85
+ const sharedSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, this.state.theirNextNostrPublicKey);
86
86
  const encryptedHeader = nip44.encrypt(JSON.stringify(header), sharedSecret);
87
87
 
88
88
  const nostrEvent = finalizeEvent({
@@ -151,13 +151,13 @@ export class Session {
151
151
  }
152
152
  }
153
153
 
154
- private ratchetStep(theirNostrPublicKey: string) {
154
+ private ratchetStep(theirNextNostrPublicKey: string) {
155
155
  this.state.previousSendingChainMessageCount = this.state.sendingChainMessageNumber;
156
156
  this.state.sendingChainMessageNumber = 0;
157
157
  this.state.receivingChainMessageNumber = 0;
158
- this.state.theirNostrPublicKey = theirNostrPublicKey;
158
+ this.state.theirNextNostrPublicKey = theirNextNostrPublicKey;
159
159
 
160
- const conversationKey1 = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, this.state.theirNostrPublicKey!);
160
+ const conversationKey1 = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, this.state.theirNextNostrPublicKey!);
161
161
  const [theirRootKey, receivingChainKey] = kdf(this.state.rootKey, conversationKey1, 2);
162
162
 
163
163
  this.state.receivingChainKey = receivingChainKey;
@@ -169,7 +169,7 @@ export class Session {
169
169
  privateKey: ourNextSecretKey
170
170
  };
171
171
 
172
- const conversationKey2 = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, this.state.theirNostrPublicKey!);
172
+ const conversationKey2 = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, this.state.theirNextNostrPublicKey!);
173
173
  const [rootKey, sendingChainKey] = kdf(theirRootKey, conversationKey2, 2);
174
174
  this.state.rootKey = rootKey;
175
175
  this.state.sendingChainKey = sendingChainKey;
@@ -261,12 +261,13 @@ export class Session {
261
261
  const [header, shouldRatchet, isSkipped] = this.decryptHeader(e);
262
262
 
263
263
  if (!isSkipped) {
264
- if (this.state.theirNostrPublicKey !== header.nextPublicKey) {
265
- this.state.theirNostrPublicKey = header.nextPublicKey;
264
+ if (this.state.theirNextNostrPublicKey !== header.nextPublicKey) {
265
+ this.state.theirCurrentNostrPublicKey = this.state.theirNextNostrPublicKey;
266
+ this.state.theirNextNostrPublicKey = header.nextPublicKey;
266
267
  this.nostrUnsubscribe?.(); // should we keep this open for a while? maybe as long as we have skipped messages?
267
268
  this.nostrUnsubscribe = this.nostrNextUnsubscribe;
268
269
  this.nostrNextUnsubscribe = this.nostrSubscribe(
269
- {authors: [this.state.theirNostrPublicKey], kinds: [MESSAGE_EVENT_KIND]},
270
+ {authors: [this.state.theirNextNostrPublicKey], kinds: [MESSAGE_EVENT_KIND]},
270
271
  (e) => this.handleNostrEvent(e)
271
272
  );
272
273
  }
@@ -290,18 +291,19 @@ export class Session {
290
291
  private subscribeToNostrEvents() {
291
292
  if (this.nostrNextUnsubscribe) return;
292
293
  this.nostrNextUnsubscribe = this.nostrSubscribe(
293
- {authors: [this.state.theirNostrPublicKey], kinds: [MESSAGE_EVENT_KIND]},
294
+ {authors: [this.state.theirNextNostrPublicKey], kinds: [MESSAGE_EVENT_KIND]},
294
295
  (e) => this.handleNostrEvent(e)
295
296
  );
296
297
 
297
- const skippedSenders = Object.keys(this.state.skippedHeaderKeys);
298
- if (skippedSenders.length > 0) {
299
- // do we want this unsubscribed on rotation or should we keep it open
300
- // in case more skipped messages are found by relays or peers?
301
- this.nostrUnsubscribe = this.nostrSubscribe(
302
- {authors: skippedSenders, kinds: [MESSAGE_EVENT_KIND]},
303
- (e) => this.handleNostrEvent(e)
304
- );
298
+ const authors = Object.keys(this.state.skippedHeaderKeys);
299
+ if (this.state.theirCurrentNostrPublicKey && !authors.includes(this.state.theirCurrentNostrPublicKey)) {
300
+ authors.push(this.state.theirCurrentNostrPublicKey)
305
301
  }
302
+ // do we want this unsubscribed on rotation or should we keep it open
303
+ // in case more skipped messages are found by relays or peers?
304
+ this.nostrUnsubscribe = this.nostrSubscribe(
305
+ {authors, kinds: [MESSAGE_EVENT_KIND]},
306
+ (e) => this.handleNostrEvent(e)
307
+ );
306
308
  }
307
309
  }
@@ -0,0 +1,182 @@
1
+ import { Session } from './Session';
2
+ import { NostrSubscribe } from './types';
3
+
4
+ interface DeviceRecord {
5
+ publicKey: string;
6
+ activeSession?: Session;
7
+ inactiveSessions: Session[];
8
+ isStale: boolean;
9
+ staleTimestamp?: number;
10
+ }
11
+
12
+ /**
13
+ * WIP: Conversation management system similar to Signal's Sesame
14
+ * https://signal.org/docs/specifications/sesame/
15
+ */
16
+ export class UserRecord {
17
+ private deviceRecords: Map<string, DeviceRecord> = new Map();
18
+ private isStale: boolean = false;
19
+ private staleTimestamp?: number;
20
+
21
+ constructor(
22
+ public userId: string,
23
+ private nostrSubscribe: NostrSubscribe,
24
+ ) {}
25
+
26
+ /**
27
+ * Adds or updates a device record for this user
28
+ */
29
+ public conditionalUpdate(deviceId: string, publicKey: string): void {
30
+ const existingRecord = this.deviceRecords.get(deviceId);
31
+
32
+ // If device record doesn't exist or public key changed, create new empty record
33
+ if (!existingRecord || existingRecord.publicKey !== publicKey) {
34
+ this.deviceRecords.set(deviceId, {
35
+ publicKey,
36
+ inactiveSessions: [],
37
+ isStale: false
38
+ });
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Inserts a new session for a device, making it the active session
44
+ */
45
+ public insertSession(deviceId: string, session: Session): void {
46
+ const record = this.deviceRecords.get(deviceId);
47
+ if (!record) {
48
+ throw new Error(`No device record found for ${deviceId}`);
49
+ }
50
+
51
+ // Move current active session to inactive list if it exists
52
+ if (record.activeSession) {
53
+ record.inactiveSessions.unshift(record.activeSession);
54
+ }
55
+
56
+ // Set new session as active
57
+ record.activeSession = session;
58
+ }
59
+
60
+ /**
61
+ * Activates an inactive session for a device
62
+ */
63
+ public activateSession(deviceId: string, session: Session): void {
64
+ const record = this.deviceRecords.get(deviceId);
65
+ if (!record) {
66
+ throw new Error(`No device record found for ${deviceId}`);
67
+ }
68
+
69
+ const sessionIndex = record.inactiveSessions.indexOf(session);
70
+ if (sessionIndex === -1) {
71
+ throw new Error('Session not found in inactive sessions');
72
+ }
73
+
74
+ // Remove session from inactive list
75
+ record.inactiveSessions.splice(sessionIndex, 1);
76
+
77
+ // Move current active session to inactive list if it exists
78
+ if (record.activeSession) {
79
+ record.inactiveSessions.unshift(record.activeSession);
80
+ }
81
+
82
+ // Set selected session as active
83
+ record.activeSession = session;
84
+ }
85
+
86
+ /**
87
+ * Marks a device record as stale
88
+ */
89
+ public markDeviceStale(deviceId: string): void {
90
+ const record = this.deviceRecords.get(deviceId);
91
+ if (record) {
92
+ record.isStale = true;
93
+ record.staleTimestamp = Date.now();
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Marks the entire user record as stale
99
+ */
100
+ public markUserStale(): void {
101
+ this.isStale = true;
102
+ this.staleTimestamp = Date.now();
103
+ }
104
+
105
+ /**
106
+ * Gets all non-stale device records with active sessions
107
+ */
108
+ public getActiveDevices(): Array<[string, Session]> {
109
+ if (this.isStale) return [];
110
+
111
+ return Array.from(this.deviceRecords.entries())
112
+ .filter(([_, record]) => !record.isStale && record.activeSession)
113
+ .map(([deviceId, record]) => [deviceId, record.activeSession!]);
114
+ }
115
+
116
+ /**
117
+ * Creates a new session for a device
118
+ */
119
+ public createSession(
120
+ deviceId: string,
121
+ sharedSecret: Uint8Array,
122
+ ourCurrentPrivateKey: Uint8Array,
123
+ isInitiator: boolean,
124
+ name?: string
125
+ ): Session {
126
+ const record = this.deviceRecords.get(deviceId);
127
+ if (!record) {
128
+ throw new Error(`No device record found for ${deviceId}`);
129
+ }
130
+
131
+ const session = Session.init(
132
+ this.nostrSubscribe,
133
+ record.publicKey,
134
+ ourCurrentPrivateKey,
135
+ isInitiator,
136
+ sharedSecret,
137
+ name
138
+ );
139
+
140
+ this.insertSession(deviceId, session);
141
+ return session;
142
+ }
143
+
144
+ /**
145
+ * Deletes stale records that are older than maxLatency
146
+ */
147
+ public pruneStaleRecords(maxLatency: number): void {
148
+ const now = Date.now();
149
+
150
+ // Delete stale device records
151
+ for (const [deviceId, record] of this.deviceRecords.entries()) {
152
+ if (record.isStale && record.staleTimestamp &&
153
+ (now - record.staleTimestamp) > maxLatency) {
154
+ // Close all sessions
155
+ record.activeSession?.close();
156
+ record.inactiveSessions.forEach(session => session.close());
157
+ this.deviceRecords.delete(deviceId);
158
+ }
159
+ }
160
+
161
+ // Delete entire user record if stale
162
+ if (this.isStale && this.staleTimestamp &&
163
+ (now - this.staleTimestamp) > maxLatency) {
164
+ this.deviceRecords.forEach(record => {
165
+ record.activeSession?.close();
166
+ record.inactiveSessions.forEach(session => session.close());
167
+ });
168
+ this.deviceRecords.clear();
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Cleanup when destroying the user record
174
+ */
175
+ public close(): void {
176
+ this.deviceRecords.forEach(record => {
177
+ record.activeSession?.close();
178
+ record.inactiveSessions.forEach(session => session.close());
179
+ });
180
+ this.deviceRecords.clear();
181
+ }
182
+ }
package/src/types.ts CHANGED
@@ -33,7 +33,10 @@ export interface SessionState {
33
33
  rootKey: Uint8Array;
34
34
 
35
35
  /** The other party's current Nostr public key */
36
- theirNostrPublicKey: string;
36
+ theirCurrentNostrPublicKey?: string;
37
+
38
+ /** The other party's next Nostr public key */
39
+ theirNextNostrPublicKey: string;
37
40
 
38
41
  /** Our current Nostr keypair used for this session */
39
42
  ourCurrentNostrKey?: KeyPair;
package/src/utils.ts CHANGED
@@ -7,7 +7,8 @@ import { sha256 } from '@noble/hashes/sha256';
7
7
  export function serializeSessionState(state: SessionState): string {
8
8
  return JSON.stringify({
9
9
  rootKey: bytesToHex(state.rootKey),
10
- theirNostrPublicKey: state.theirNostrPublicKey,
10
+ theirCurrentNostrPublicKey: state.theirCurrentNostrPublicKey,
11
+ theirNextNostrPublicKey: state.theirNextNostrPublicKey,
11
12
  ourCurrentNostrKey: state.ourCurrentNostrKey ? {
12
13
  publicKey: state.ourCurrentNostrKey.publicKey,
13
14
  privateKey: bytesToHex(state.ourCurrentNostrKey.privateKey),
@@ -40,7 +41,8 @@ export function deserializeSessionState(data: string): SessionState {
40
41
  const state = JSON.parse(data);
41
42
  return {
42
43
  rootKey: hexToBytes(state.rootKey),
43
- theirNostrPublicKey: state.theirNostrPublicKey,
44
+ theirCurrentNostrPublicKey: state.theirCurrentNostrPublicKey,
45
+ theirNextNostrPublicKey: state.theirNextNostrPublicKey,
44
46
  ourCurrentNostrKey: state.ourCurrentNostrKey ? {
45
47
  publicKey: state.ourCurrentNostrKey.publicKey,
46
48
  privateKey: hexToBytes(state.ourCurrentNostrKey.privateKey),