nostr-double-ratchet 0.0.13 → 0.0.15

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
  /**
@@ -19,7 +17,7 @@ import { hexToBytes, bytesToHex } from "@noble/hashes/utils";
19
17
  export class Invite {
20
18
  constructor(
21
19
  public inviterEphemeralPublicKey: string,
22
- public linkSecret: string,
20
+ public sharedSecret: string,
23
21
  public inviter: string,
24
22
  public inviterEphemeralPrivateKey?: Uint8Array,
25
23
  public label?: string,
@@ -33,10 +31,10 @@ export class Invite {
33
31
  }
34
32
  const inviterEphemeralPrivateKey = generateSecretKey();
35
33
  const inviterEphemeralPublicKey = getPublicKey(inviterEphemeralPrivateKey);
36
- const linkSecret = bytesToHex(generateSecretKey());
34
+ const sharedSecret = bytesToHex(generateSecretKey());
37
35
  return new Invite(
38
36
  inviterEphemeralPublicKey,
39
- linkSecret,
37
+ sharedSecret,
40
38
  inviter,
41
39
  inviterEphemeralPrivateKey,
42
40
  label,
@@ -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, ephemeralKey, linkSecret } = data;
63
- if (!inviter || !ephemeralKey || !linkSecret) {
64
- throw new Error("Missing required fields (inviter, ephemeralKey, 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
66
  ephemeralKey,
69
- linkSecret,
67
+ sharedSecret,
70
68
  inviter
71
69
  );
72
70
  }
@@ -75,7 +73,7 @@ export class Invite {
75
73
  const data = JSON.parse(json);
76
74
  return new Invite(
77
75
  data.inviterEphemeralPublicKey,
78
- data.linkSecret,
76
+ data.sharedSecret,
79
77
  data.inviter,
80
78
  data.inviterEphemeralPrivateKey ? new Uint8Array(data.inviterEphemeralPrivateKey) : undefined,
81
79
  data.label,
@@ -93,16 +91,16 @@ export class Invite {
93
91
  }
94
92
  const { tags } = event;
95
93
  const inviterEphemeralPublicKey = tags.find(([key]) => key === 'ephemeralKey')?.[1];
96
- const linkSecret = tags.find(([key]) => key === 'linkSecret')?.[1];
94
+ const sharedSecret = tags.find(([key]) => key === 'sharedSecret')?.[1];
97
95
  const inviter = event.pubkey;
98
96
 
99
- if (!inviterEphemeralPublicKey || !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
102
  inviterEphemeralPublicKey,
105
- linkSecret,
103
+ sharedSecret,
106
104
  inviter
107
105
  );
108
106
  }
@@ -137,7 +135,7 @@ export class Invite {
137
135
  serialize(): string {
138
136
  return JSON.stringify({
139
137
  inviterEphemeralPublicKey: this.inviterEphemeralPublicKey,
140
- linkSecret: this.linkSecret,
138
+ sharedSecret: this.sharedSecret,
141
139
  inviter: this.inviter,
142
140
  inviterEphemeralPrivateKey: this.inviterEphemeralPrivateKey ? Array.from(this.inviterEphemeralPrivateKey) : undefined,
143
141
  label: this.label,
@@ -153,11 +151,10 @@ export class Invite {
153
151
  const data = {
154
152
  inviter: this.inviter,
155
153
  ephemeralKey: this.inviterEphemeralPublicKey,
156
- linkSecret: this.linkSecret
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: [['ephemeralKey', this.inviterEphemeralPublicKey], ['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,27 +186,31 @@ 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
193
  const inviterPublicKey = this.inviter || this.inviterEphemeralPublicKey;
197
194
 
198
- const sharedSecret = hexToBytes(this.linkSecret);
195
+ const sharedSecret = hexToBytes(this.sharedSecret);
199
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
 
@@ -224,7 +225,7 @@ export class Invite {
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
+ listen(decryptor: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (session: Session, identity?: string) => void): Unsubscribe {
228
229
  if (!this.inviterEphemeralPrivateKey) {
229
230
  throw new Error("Inviter session key is not available");
230
231
  }
@@ -241,23 +242,28 @@ export class Invite {
241
242
  return;
242
243
  }
243
244
 
245
+ // Decrypt the outer envelope first
244
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
269
  const session = Session.init(nostrSubscribe, inviteeSessionPublicKey, this.inviterEphemeralPrivateKey!, false, sharedSecret, name);
package/src/Session.ts CHANGED
@@ -1,17 +1,22 @@
1
- import { generateSecretKey, getPublicKey, nip44, finalizeEvent, VerifiedEvent } from "nostr-tools";
1
+ import { generateSecretKey, getPublicKey, nip44, finalizeEvent, VerifiedEvent, UnsignedEvent, getEventHash } from "nostr-tools";
2
2
  import { bytesToHex } from "@noble/hashes/utils";
3
3
  import {
4
4
  SessionState,
5
5
  Header,
6
6
  Unsubscribe,
7
7
  NostrSubscribe,
8
- MessageCallback,
8
+ EventCallback,
9
9
  MESSAGE_EVENT_KIND,
10
+ Rumor,
11
+ CHAT_MESSAGE_KIND,
10
12
  } from "./types";
11
13
  import { kdf, skippedMessageIndexKey } from "./utils";
12
14
 
13
15
  const MAX_SKIP = 1000;
14
16
 
17
+ // 64 zeros
18
+ const DUMMY_PUBKEY = '0000000000000000000000000000000000000000000000000000000000000000'
19
+
15
20
  /**
16
21
  * Double ratchet secure communication session over Nostr
17
22
  *
@@ -21,7 +26,7 @@ const MAX_SKIP = 1000;
21
26
  export class Session {
22
27
  private nostrUnsubscribe?: Unsubscribe;
23
28
  private nostrNextUnsubscribe?: Unsubscribe;
24
- private internalSubscriptions = new Map<number, MessageCallback>();
29
+ private internalSubscriptions = new Map<number, EventCallback>();
25
30
  private currentInternalSubscriptionId = 0;
26
31
  public name: string;
27
32
 
@@ -33,16 +38,16 @@ export class Session {
33
38
  /**
34
39
  * Initializes a new secure communication session
35
40
  * @param nostrSubscribe Function to subscribe to Nostr events
36
- * @param theirNostrPublicKey The public key of the other party
41
+ * @param theirNextNostrPublicKey The public key of the other party
37
42
  * @param ourCurrentPrivateKey Our current private key for Nostr
38
43
  * @param isInitiator Whether we are initiating the conversation (true) or responding (false)
39
44
  * @param sharedSecret Initial shared secret for securing the first message chain
40
45
  * @param name Optional name for the session (for debugging)
41
46
  * @returns A new Session instance
42
47
  */
43
- static init(nostrSubscribe: NostrSubscribe, theirNostrPublicKey: string, ourCurrentPrivateKey: Uint8Array, isInitiator: boolean, sharedSecret: Uint8Array, name?: string): Session {
48
+ static init(nostrSubscribe: NostrSubscribe, theirNextNostrPublicKey: string, ourCurrentPrivateKey: Uint8Array, isInitiator: boolean, sharedSecret: Uint8Array, name?: string): Session {
44
49
  const ourNextPrivateKey = generateSecretKey();
45
- const [rootKey, sendingChainKey] = kdf(sharedSecret, nip44.getConversationKey(ourNextPrivateKey, theirNostrPublicKey), 2);
50
+ const [rootKey, sendingChainKey] = kdf(sharedSecret, nip44.getConversationKey(ourNextPrivateKey, theirNextNostrPublicKey), 2);
46
51
  let ourCurrentNostrKey;
47
52
  let ourNextNostrKey;
48
53
  if (isInitiator) {
@@ -53,7 +58,7 @@ export class Session {
53
58
  }
54
59
  const state: SessionState = {
55
60
  rootKey: isInitiator ? rootKey : sharedSecret,
56
- theirNostrPublicKey,
61
+ theirNextNostrPublicKey,
57
62
  ourCurrentNostrKey,
58
63
  ourNextNostrKey,
59
64
  receivingChainKey: undefined,
@@ -70,19 +75,51 @@ export class Session {
70
75
  }
71
76
 
72
77
  /**
73
- * Sends an encrypted message through the session
74
- * @param data The plaintext message to send
78
+ * Sends a text message through the encrypted session
79
+ * @param text The plaintext message to send
80
+ * @returns A verified Nostr event containing the encrypted message
81
+ * @throws Error if we are not the initiator and trying to send the first message
82
+ */
83
+ send(text: string): VerifiedEvent {
84
+ return this.sendEvent({
85
+ content: text,
86
+ kind: CHAT_MESSAGE_KIND
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Send a Nostr event through the encrypted session
92
+ * @param event Partial Nostr event to send. Must be unsigned. Id and will be generated if not provided.
75
93
  * @returns A verified Nostr event containing the encrypted message
76
94
  * @throws Error if we are not the initiator and trying to send the first message
77
95
  */
78
- send(data: string): VerifiedEvent {
79
- if (!this.state.theirNostrPublicKey || !this.state.ourCurrentNostrKey) {
96
+ sendEvent(event: Partial<UnsignedEvent>): VerifiedEvent {
97
+ if (!this.state.theirNextNostrPublicKey || !this.state.ourCurrentNostrKey) {
80
98
  throw new Error("we are not the initiator, so we can't send the first message");
81
99
  }
82
100
 
83
- const [header, encryptedData] = this.ratchetEncrypt(data);
101
+ if ("sig" in event) {
102
+ throw new Error("Event must be unsigned " + JSON.stringify(event));
103
+ }
104
+
105
+ const rumor: Partial<Rumor> = {
106
+ ...event,
107
+ content: event.content || "",
108
+ kind: event.kind || MESSAGE_EVENT_KIND,
109
+ created_at: event.created_at || Math.floor(Date.now() / 1000),
110
+ tags: event.tags || [],
111
+ pubkey: event.pubkey || DUMMY_PUBKEY,
112
+ }
113
+
114
+ if (!rumor.tags!.some(([k]) => k === "ms")) {
115
+ rumor.tags!.push(["ms", Date.now().toString()])
116
+ }
117
+
118
+ rumor.id = getEventHash(rumor as Rumor);
119
+
120
+ const [header, encryptedData] = this.ratchetEncrypt(JSON.stringify(event));
84
121
 
85
- const sharedSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, this.state.theirNostrPublicKey);
122
+ const sharedSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, this.state.theirNextNostrPublicKey);
86
123
  const encryptedHeader = nip44.encrypt(JSON.stringify(header), sharedSecret);
87
124
 
88
125
  const nostrEvent = finalizeEvent({
@@ -100,7 +137,7 @@ export class Session {
100
137
  * @param callback Function to be called when a message is received
101
138
  * @returns Unsubscribe function to stop receiving messages
102
139
  */
103
- onMessage(callback: MessageCallback): Unsubscribe {
140
+ onEvent(callback: EventCallback): Unsubscribe {
104
141
  const id = this.currentInternalSubscriptionId++
105
142
  this.internalSubscriptions.set(id, callback)
106
143
  this.subscribeToNostrEvents()
@@ -122,7 +159,6 @@ export class Session {
122
159
  const header: Header = {
123
160
  number: this.state.sendingChainMessageNumber++,
124
161
  nextPublicKey: this.state.ourNextNostrKey.publicKey,
125
- time: Date.now(),
126
162
  previousChainLength: this.state.previousSendingChainMessageCount
127
163
  };
128
164
  return [header, nip44.encrypt(plaintext, messageKey)];
@@ -151,13 +187,13 @@ export class Session {
151
187
  }
152
188
  }
153
189
 
154
- private ratchetStep(theirNostrPublicKey: string) {
190
+ private ratchetStep(theirNextNostrPublicKey: string) {
155
191
  this.state.previousSendingChainMessageCount = this.state.sendingChainMessageNumber;
156
192
  this.state.sendingChainMessageNumber = 0;
157
193
  this.state.receivingChainMessageNumber = 0;
158
- this.state.theirNostrPublicKey = theirNostrPublicKey;
194
+ this.state.theirNextNostrPublicKey = theirNextNostrPublicKey;
159
195
 
160
- const conversationKey1 = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, this.state.theirNostrPublicKey!);
196
+ const conversationKey1 = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, this.state.theirNextNostrPublicKey!);
161
197
  const [theirRootKey, receivingChainKey] = kdf(this.state.rootKey, conversationKey1, 2);
162
198
 
163
199
  this.state.receivingChainKey = receivingChainKey;
@@ -169,7 +205,7 @@ export class Session {
169
205
  privateKey: ourNextSecretKey
170
206
  };
171
207
 
172
- const conversationKey2 = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, this.state.theirNostrPublicKey!);
208
+ const conversationKey2 = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, this.state.theirNextNostrPublicKey!);
173
209
  const [rootKey, sendingChainKey] = kdf(theirRootKey, conversationKey2, 2);
174
210
  this.state.rootKey = rootKey;
175
211
  this.state.sendingChainKey = sendingChainKey;
@@ -261,12 +297,13 @@ export class Session {
261
297
  const [header, shouldRatchet, isSkipped] = this.decryptHeader(e);
262
298
 
263
299
  if (!isSkipped) {
264
- if (this.state.theirNostrPublicKey !== header.nextPublicKey) {
265
- this.state.theirNostrPublicKey = header.nextPublicKey;
300
+ if (this.state.theirNextNostrPublicKey !== header.nextPublicKey) {
301
+ this.state.theirCurrentNostrPublicKey = this.state.theirNextNostrPublicKey;
302
+ this.state.theirNextNostrPublicKey = header.nextPublicKey;
266
303
  this.nostrUnsubscribe?.(); // should we keep this open for a while? maybe as long as we have skipped messages?
267
304
  this.nostrUnsubscribe = this.nostrNextUnsubscribe;
268
305
  this.nostrNextUnsubscribe = this.nostrSubscribe(
269
- {authors: [this.state.theirNostrPublicKey], kinds: [MESSAGE_EVENT_KIND]},
306
+ {authors: [this.state.theirNextNostrPublicKey], kinds: [MESSAGE_EVENT_KIND]},
270
307
  (e) => this.handleNostrEvent(e)
271
308
  );
272
309
  }
@@ -282,26 +319,28 @@ export class Session {
282
319
  }
283
320
  }
284
321
 
285
- const data = this.ratchetDecrypt(header, e.content, e.pubkey);
322
+ const text = this.ratchetDecrypt(header, e.content, e.pubkey);
323
+ const innerEvent = JSON.parse(text);
286
324
 
287
- this.internalSubscriptions.forEach(callback => callback({id: e.id, data, pubkey: header.nextPublicKey, time: header.time}));
325
+ this.internalSubscriptions.forEach(callback => callback(innerEvent, e));
288
326
  }
289
327
 
290
328
  private subscribeToNostrEvents() {
291
329
  if (this.nostrNextUnsubscribe) return;
292
330
  this.nostrNextUnsubscribe = this.nostrSubscribe(
293
- {authors: [this.state.theirNostrPublicKey], kinds: [MESSAGE_EVENT_KIND]},
331
+ {authors: [this.state.theirNextNostrPublicKey], kinds: [MESSAGE_EVENT_KIND]},
294
332
  (e) => this.handleNostrEvent(e)
295
333
  );
296
334
 
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
- );
335
+ const authors = Object.keys(this.state.skippedHeaderKeys);
336
+ if (this.state.theirCurrentNostrPublicKey && !authors.includes(this.state.theirCurrentNostrPublicKey)) {
337
+ authors.push(this.state.theirCurrentNostrPublicKey)
305
338
  }
339
+ // do we want this unsubscribed on rotation or should we keep it open
340
+ // in case more skipped messages are found by relays or peers?
341
+ this.nostrUnsubscribe = this.nostrSubscribe(
342
+ {authors, kinds: [MESSAGE_EVENT_KIND]},
343
+ (e) => this.handleNostrEvent(e)
344
+ );
306
345
  }
307
346
  }
package/src/UserRecord.ts CHANGED
@@ -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
@@ -1,20 +1,9 @@
1
1
  import { Filter, UnsignedEvent, VerifiedEvent } from "nostr-tools";
2
2
 
3
- /**
4
- * An event that has been verified to be from the Nostr network.
5
- */
6
- export type Message = {
7
- id: string;
8
- data: string;
9
- pubkey: string;
10
- time: number; // unlike Nostr, we use milliseconds instead of seconds
11
- }
12
-
13
3
  export type Header = {
14
4
  number: number;
15
5
  previousChainLength: number;
16
6
  nextPublicKey: string;
17
- time: number;
18
7
  }
19
8
 
20
9
  /**
@@ -33,7 +22,10 @@ export interface SessionState {
33
22
  rootKey: Uint8Array;
34
23
 
35
24
  /** The other party's current Nostr public key */
36
- theirNostrPublicKey: string;
25
+ theirCurrentNostrPublicKey?: string;
26
+
27
+ /** The other party's next Nostr public key */
28
+ theirNextNostrPublicKey: string;
37
29
 
38
30
  /** Our current Nostr keypair used for this session */
39
31
  ourCurrentNostrKey?: KeyPair;
@@ -73,14 +65,26 @@ export type Unsubscribe = () => void;
73
65
  */
74
66
  export type NostrSubscribe = (filter: Filter, onEvent: (e: VerifiedEvent) => void) => Unsubscribe;
75
67
 
68
+ export type Rumor = UnsignedEvent & { id: string }
69
+
76
70
  /**
77
71
  * Callback function for handling decrypted messages
78
72
  * @param message - The decrypted message object
79
73
  */
80
- export type MessageCallback = (message: Message) => void;
74
+ export type EventCallback = (event: Rumor, outerEvent: VerifiedEvent) => void;
81
75
 
82
- export const MESSAGE_EVENT_KIND = 4;
76
+ /**
77
+ * Message event kind
78
+ */
79
+ export const MESSAGE_EVENT_KIND = 30078;
80
+
81
+ /**
82
+ * Invite event kind
83
+ */
83
84
  export const INVITE_EVENT_KIND = 30078;
85
+
86
+ export const CHAT_MESSAGE_KIND = 14;
87
+
84
88
  export const MAX_SKIP = 100;
85
89
 
86
90
  export type NostrEvent = {
@@ -93,11 +97,6 @@ export type NostrEvent = {
93
97
  sig: string;
94
98
  }
95
99
 
96
- export enum Sender {
97
- Us,
98
- Them
99
- }
100
-
101
100
  export type EncryptFunction = (plaintext: string, pubkey: string) => Promise<string>;
102
101
  export type DecryptFunction = (ciphertext: string, pubkey: string) => Promise<string>;
103
102
  export type NostrPublish = (event: UnsignedEvent) => Promise<VerifiedEvent>;