nostr-double-ratchet 0.0.27 → 0.0.29
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/README.md +1 -1
- package/dist/Invite.d.ts +7 -6
- package/dist/Invite.d.ts.map +1 -1
- package/dist/Session.d.ts +3 -3
- package/dist/Session.d.ts.map +1 -1
- package/dist/SessionManager.d.ts +38 -0
- package/dist/SessionManager.d.ts.map +1 -0
- package/dist/StorageAdapter.d.ts +18 -0
- package/dist/StorageAdapter.d.ts.map +1 -0
- package/dist/UserRecord.d.ts +21 -3
- package/dist/UserRecord.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/nostr-double-ratchet.es.js +1407 -1281
- package/dist/nostr-double-ratchet.umd.js +1 -1
- package/dist/types.d.ts +7 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/package.json +19 -17
- package/src/Invite.ts +32 -27
- package/src/Session.ts +11 -23
- package/src/SessionManager.ts +328 -0
- package/src/StorageAdapter.ts +43 -0
- package/src/UserRecord.ts +75 -18
- package/src/index.ts +3 -2
- package/src/types.ts +7 -6
- package/src/utils.ts +12 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nostr-double-ratchet",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.29",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"packageManager": "yarn@1.22.22",
|
|
6
6
|
"description": "Nostr double ratchet library",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"build": "vite build && tsc",
|
|
15
15
|
"examples": "cd examples && vite build && tsc",
|
|
16
16
|
"examples-dev": "cd examples && vite && tsc",
|
|
17
|
-
"docs": "typedoc --out docs src/index.ts"
|
|
17
|
+
"docs": "typedoc --out docs src/index.ts",
|
|
18
|
+
"lint": "eslint src --ext .ts --fix"
|
|
18
19
|
},
|
|
19
20
|
"repository": {
|
|
20
21
|
"type": "git",
|
|
@@ -32,25 +33,26 @@
|
|
|
32
33
|
"dist"
|
|
33
34
|
],
|
|
34
35
|
"devDependencies": {
|
|
35
|
-
"@nostr-dev-kit/ndk": "2.
|
|
36
|
-
"@types/lodash": "^4.17.
|
|
37
|
-
"@types/node": "^22.
|
|
38
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
39
|
-
"@typescript-eslint/parser": "^8.
|
|
40
|
-
"eslint": "^9.
|
|
41
|
-
"eslint-config-prettier": "^10.
|
|
42
|
-
"eslint-plugin-prettier": "^5.
|
|
36
|
+
"@nostr-dev-kit/ndk": "2.14.23",
|
|
37
|
+
"@types/lodash": "^4.17.17",
|
|
38
|
+
"@types/node": "^22.15.21",
|
|
39
|
+
"@typescript-eslint/eslint-plugin": "^8.33.1",
|
|
40
|
+
"@typescript-eslint/parser": "^8.33.1",
|
|
41
|
+
"eslint": "^9.28.0",
|
|
42
|
+
"eslint-config-prettier": "^10.1.5",
|
|
43
|
+
"eslint-plugin-prettier": "^5.4.0",
|
|
43
44
|
"eslint-plugin-simple-import-sort": "^12.1.1",
|
|
44
45
|
"lodash": "^4.17.21",
|
|
45
|
-
"tsx": "^4.19.
|
|
46
|
-
"typedoc": "^0.
|
|
47
|
-
"typescript": "^5.
|
|
46
|
+
"tsx": "^4.19.4",
|
|
47
|
+
"typedoc": "^0.28.4",
|
|
48
|
+
"typescript": "^5.8.3",
|
|
48
49
|
"typescript-lru-cache": "^2.0.0",
|
|
49
|
-
"vite": "^6.
|
|
50
|
-
"vitest": "^3.
|
|
51
|
-
"ws": "^8.18.
|
|
50
|
+
"vite": "^6.3.5",
|
|
51
|
+
"vitest": "^3.1.4",
|
|
52
|
+
"ws": "^8.18.2"
|
|
52
53
|
},
|
|
53
54
|
"dependencies": {
|
|
54
|
-
"nostr-tools": "^2.
|
|
55
|
+
"nostr-tools": "^2.15.0",
|
|
56
|
+
"react-blurhash": "^0.3.0"
|
|
55
57
|
}
|
|
56
58
|
}
|
package/src/Invite.ts
CHANGED
|
@@ -25,12 +25,13 @@ export class Invite {
|
|
|
25
25
|
public sharedSecret: string,
|
|
26
26
|
public inviter: string,
|
|
27
27
|
public inviterEphemeralPrivateKey?: Uint8Array,
|
|
28
|
-
public
|
|
28
|
+
public deviceId?: string,
|
|
29
29
|
public maxUses?: number,
|
|
30
30
|
public usedBy: string[] = [],
|
|
31
|
-
) {
|
|
31
|
+
) {
|
|
32
|
+
}
|
|
32
33
|
|
|
33
|
-
static createNew(inviter: string,
|
|
34
|
+
static createNew(inviter: string, deviceId?: string, maxUses?: number): Invite {
|
|
34
35
|
if (!inviter) {
|
|
35
36
|
throw new Error("Inviter public key is required");
|
|
36
37
|
}
|
|
@@ -42,7 +43,7 @@ export class Invite {
|
|
|
42
43
|
sharedSecret,
|
|
43
44
|
inviter,
|
|
44
45
|
inviterEphemeralPrivateKey,
|
|
45
|
-
|
|
46
|
+
deviceId,
|
|
46
47
|
maxUses
|
|
47
48
|
);
|
|
48
49
|
}
|
|
@@ -55,7 +56,7 @@ export class Invite {
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
const decodedHash = decodeURIComponent(rawHash);
|
|
58
|
-
let data:
|
|
59
|
+
let data: { inviter?: string; ephemeralKey?: string; sharedSecret?: string };
|
|
59
60
|
try {
|
|
60
61
|
data = JSON.parse(decodedHash);
|
|
61
62
|
} catch (err) {
|
|
@@ -81,7 +82,7 @@ export class Invite {
|
|
|
81
82
|
data.sharedSecret,
|
|
82
83
|
data.inviter,
|
|
83
84
|
data.inviterEphemeralPrivateKey ? new Uint8Array(data.inviterEphemeralPrivateKey) : undefined,
|
|
84
|
-
data.
|
|
85
|
+
data.deviceId,
|
|
85
86
|
data.maxUses,
|
|
86
87
|
data.usedBy
|
|
87
88
|
);
|
|
@@ -104,6 +105,10 @@ export class Invite {
|
|
|
104
105
|
const sharedSecret = tags.find(([key]) => key === 'sharedSecret')?.[1];
|
|
105
106
|
const inviter = event.pubkey;
|
|
106
107
|
|
|
108
|
+
// Extract deviceId from the "d" tag (format: double-ratchet/invites/<deviceId>)
|
|
109
|
+
const deviceTag = tags.find(([key]) => key === 'd')?.[1]
|
|
110
|
+
const deviceId = deviceTag?.split('/')?.[2]
|
|
111
|
+
|
|
107
112
|
if (!inviterEphemeralPublicKey || !sharedSecret) {
|
|
108
113
|
throw new Error("Invalid invite event: missing session key or sharedSecret");
|
|
109
114
|
}
|
|
@@ -111,28 +116,26 @@ export class Invite {
|
|
|
111
116
|
return new Invite(
|
|
112
117
|
inviterEphemeralPublicKey,
|
|
113
118
|
sharedSecret,
|
|
114
|
-
inviter
|
|
119
|
+
inviter,
|
|
120
|
+
undefined, // inviterEphemeralPrivateKey not available when parsing from event
|
|
121
|
+
deviceId
|
|
115
122
|
);
|
|
116
123
|
}
|
|
117
124
|
|
|
118
|
-
static fromUser(user: string, subscribe: NostrSubscribe, onInvite: (
|
|
125
|
+
static fromUser(user: string, subscribe: NostrSubscribe, onInvite: (_invite: Invite) => void): Unsubscribe {
|
|
119
126
|
const filter: Filter = {
|
|
120
127
|
kinds: [INVITE_EVENT_KIND],
|
|
121
128
|
authors: [user],
|
|
122
|
-
|
|
123
|
-
"#d": ["double-ratchet/invites/public"],
|
|
129
|
+
"#l": ["double-ratchet/invites"]
|
|
124
130
|
};
|
|
125
|
-
|
|
131
|
+
const seenIds = new Set<string>()
|
|
126
132
|
const unsub = subscribe(filter, (event) => {
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
latest = event.created_at;
|
|
133
|
+
if (seenIds.has(event.id)) return
|
|
134
|
+
seenIds.add(event.id)
|
|
131
135
|
try {
|
|
132
136
|
const inviteLink = Invite.fromEvent(event);
|
|
133
137
|
onInvite(inviteLink);
|
|
134
|
-
} catch
|
|
135
|
-
console.error("Error processing invite:", error, "event:", event);
|
|
138
|
+
} catch {
|
|
136
139
|
}
|
|
137
140
|
});
|
|
138
141
|
|
|
@@ -148,7 +151,7 @@ export class Invite {
|
|
|
148
151
|
sharedSecret: this.sharedSecret,
|
|
149
152
|
inviter: this.inviter,
|
|
150
153
|
inviterEphemeralPrivateKey: this.inviterEphemeralPrivateKey ? Array.from(this.inviterEphemeralPrivateKey) : undefined,
|
|
151
|
-
|
|
154
|
+
deviceId: this.deviceId,
|
|
152
155
|
maxUses: this.maxUses,
|
|
153
156
|
usedBy: this.usedBy,
|
|
154
157
|
});
|
|
@@ -169,6 +172,9 @@ export class Invite {
|
|
|
169
172
|
}
|
|
170
173
|
|
|
171
174
|
getEvent(): UnsignedEvent {
|
|
175
|
+
if (!this.deviceId) {
|
|
176
|
+
throw new Error("Device ID is required");
|
|
177
|
+
}
|
|
172
178
|
return {
|
|
173
179
|
kind: INVITE_EVENT_KIND,
|
|
174
180
|
pubkey: this.inviter,
|
|
@@ -177,7 +183,7 @@ export class Invite {
|
|
|
177
183
|
tags: [
|
|
178
184
|
['ephemeralKey', this.inviterEphemeralPublicKey],
|
|
179
185
|
['sharedSecret', this.sharedSecret],
|
|
180
|
-
['d', 'double-ratchet/invites/
|
|
186
|
+
['d', 'double-ratchet/invites/' + this.deviceId],
|
|
181
187
|
['l', 'double-ratchet/invites']
|
|
182
188
|
],
|
|
183
189
|
};
|
|
@@ -185,11 +191,12 @@ export class Invite {
|
|
|
185
191
|
|
|
186
192
|
/**
|
|
187
193
|
* Called by the invitee. Accepts the invite and creates a new session with the inviter.
|
|
188
|
-
*
|
|
189
|
-
* @param inviteeSecretKey - The invitee's secret key or a signing function
|
|
194
|
+
*
|
|
190
195
|
* @param nostrSubscribe - A function to subscribe to Nostr events
|
|
196
|
+
* @param inviteePublicKey - The invitee's public key
|
|
197
|
+
* @param encryptor - The invitee's secret key or a signing/encrypt function
|
|
191
198
|
* @returns An object containing the new session and an event to be published
|
|
192
|
-
*
|
|
199
|
+
*
|
|
193
200
|
* 1. Inner event: No signature, content encrypted with DH(inviter, invitee).
|
|
194
201
|
* Purpose: Authenticate invitee. Contains invitee session key.
|
|
195
202
|
* 2. Envelope: No signature, content encrypted with DH(inviter, random key).
|
|
@@ -240,7 +247,7 @@ export class Invite {
|
|
|
240
247
|
return { session, event: finalizeEvent(envelope, randomSenderKey) };
|
|
241
248
|
}
|
|
242
249
|
|
|
243
|
-
listen(decryptor: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (
|
|
250
|
+
listen(decryptor: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (_session: Session, _identity?: string) => void): Unsubscribe {
|
|
244
251
|
if (!this.inviterEphemeralPrivateKey) {
|
|
245
252
|
throw new Error("Inviter session key is not available");
|
|
246
253
|
}
|
|
@@ -253,7 +260,6 @@ export class Invite {
|
|
|
253
260
|
return nostrSubscribe(filter, async (event) => {
|
|
254
261
|
try {
|
|
255
262
|
if (this.maxUses && this.usedBy.length >= this.maxUses) {
|
|
256
|
-
console.error("Invite has reached maximum number of uses");
|
|
257
263
|
return;
|
|
258
264
|
}
|
|
259
265
|
|
|
@@ -279,9 +285,8 @@ export class Invite {
|
|
|
279
285
|
const session = Session.init(nostrSubscribe, inviteeSessionPublicKey, this.inviterEphemeralPrivateKey!, false, sharedSecret, name);
|
|
280
286
|
|
|
281
287
|
onSession(session, inviteeIdentity);
|
|
282
|
-
} catch
|
|
283
|
-
console.error("Error processing invite message:", error, "event", event);
|
|
288
|
+
} catch {
|
|
284
289
|
}
|
|
285
290
|
});
|
|
286
291
|
}
|
|
287
|
-
}
|
|
292
|
+
}
|
package/src/Session.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { generateSecretKey, getPublicKey, nip44, finalizeEvent, VerifiedEvent, UnsignedEvent, getEventHash, validateEvent } from "nostr-tools";
|
|
2
|
-
|
|
2
|
+
|
|
3
3
|
import {
|
|
4
4
|
SessionState,
|
|
5
5
|
Header,
|
|
@@ -38,9 +38,9 @@ export class Session {
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Initializes a new secure communication session
|
|
41
|
-
* @param nostrSubscribe Function to subscribe to Nostr events
|
|
42
|
-
* @param
|
|
43
|
-
* @param
|
|
41
|
+
* @param nostrSubscribe Function to subscribe to Nostr events. Make sure it deduplicates events (doesn't return the same event twice), otherwise you'll see decryption errors!
|
|
42
|
+
* @param theirEphemeralNostrPublicKey The ephemeral public key of the other party for the initial handshake
|
|
43
|
+
* @param ourEphemeralNostrPrivateKey Our ephemeral private key for the initial handshake
|
|
44
44
|
* @param isInitiator Whether we are initiating the conversation (true) or responding (false)
|
|
45
45
|
* @param sharedSecret Initial shared secret for securing the first message chain
|
|
46
46
|
* @param name Optional name for the session (for debugging)
|
|
@@ -205,17 +205,7 @@ export class Session {
|
|
|
205
205
|
this.state.receivingChainKey = newReceivingChainKey;
|
|
206
206
|
this.state.receivingChainMessageNumber++;
|
|
207
207
|
|
|
208
|
-
|
|
209
|
-
return nip44.decrypt(ciphertext, messageKey);
|
|
210
|
-
} catch (error) {
|
|
211
|
-
console.error(this.name, 'Decryption failed:', error, {
|
|
212
|
-
messageKey: bytesToHex(messageKey).slice(0, 4),
|
|
213
|
-
receivingChainKey: bytesToHex(this.state.receivingChainKey).slice(0, 4),
|
|
214
|
-
sendingChainKey: this.state.sendingChainKey && bytesToHex(this.state.sendingChainKey).slice(0, 4),
|
|
215
|
-
rootKey: bytesToHex(this.state.rootKey).slice(0, 4)
|
|
216
|
-
});
|
|
217
|
-
throw error;
|
|
218
|
-
}
|
|
208
|
+
return nip44.decrypt(ciphertext, messageKey);
|
|
219
209
|
}
|
|
220
210
|
|
|
221
211
|
private ratchetStep() {
|
|
@@ -294,14 +284,14 @@ export class Session {
|
|
|
294
284
|
}
|
|
295
285
|
|
|
296
286
|
// 4. NOSTR EVENT HANDLING
|
|
297
|
-
private decryptHeader(event:
|
|
287
|
+
private decryptHeader(event: { tags: string[][]; pubkey: string }): [Header, boolean, boolean] {
|
|
298
288
|
const encryptedHeader = event.tags[0][1];
|
|
299
289
|
if (this.state.ourCurrentNostrKey) {
|
|
300
290
|
const currentSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, event.pubkey);
|
|
301
291
|
try {
|
|
302
292
|
const header = JSON.parse(nip44.decrypt(encryptedHeader, currentSecret)) as Header;
|
|
303
293
|
return [header, false, false];
|
|
304
|
-
} catch
|
|
294
|
+
} catch {
|
|
305
295
|
// Decryption with currentSecret failed, try with nextSecret
|
|
306
296
|
}
|
|
307
297
|
}
|
|
@@ -310,7 +300,7 @@ export class Session {
|
|
|
310
300
|
try {
|
|
311
301
|
const header = JSON.parse(nip44.decrypt(encryptedHeader, nextSecret)) as Header;
|
|
312
302
|
return [header, true, false];
|
|
313
|
-
} catch
|
|
303
|
+
} catch {
|
|
314
304
|
// Decryption with nextSecret also failed
|
|
315
305
|
}
|
|
316
306
|
|
|
@@ -320,7 +310,7 @@ export class Session {
|
|
|
320
310
|
try {
|
|
321
311
|
const header = JSON.parse(nip44.decrypt(encryptedHeader, key)) as Header;
|
|
322
312
|
return [header, false, true];
|
|
323
|
-
} catch
|
|
313
|
+
} catch {
|
|
324
314
|
// Decryption failed, try next secret
|
|
325
315
|
}
|
|
326
316
|
}
|
|
@@ -329,7 +319,7 @@ export class Session {
|
|
|
329
319
|
throw new Error("Failed to decrypt header with current and skipped header keys");
|
|
330
320
|
}
|
|
331
321
|
|
|
332
|
-
private handleNostrEvent(e:
|
|
322
|
+
private handleNostrEvent(e: { tags: string[][]; pubkey: string; content: string }) {
|
|
333
323
|
const [header, shouldRatchet, isSkipped] = this.decryptHeader(e);
|
|
334
324
|
|
|
335
325
|
if (!isSkipped) {
|
|
@@ -358,16 +348,14 @@ export class Session {
|
|
|
358
348
|
const text = this.ratchetDecrypt(header, e.content, e.pubkey);
|
|
359
349
|
const innerEvent = JSON.parse(text);
|
|
360
350
|
if (!validateEvent(innerEvent)) {
|
|
361
|
-
console.error("Invalid event received", innerEvent);
|
|
362
351
|
return;
|
|
363
352
|
}
|
|
364
353
|
|
|
365
354
|
if (innerEvent.id !== getEventHash(innerEvent)) {
|
|
366
|
-
console.error("Event hash does not match", innerEvent);
|
|
367
355
|
return;
|
|
368
356
|
}
|
|
369
357
|
|
|
370
|
-
this.internalSubscriptions.forEach(callback => callback(innerEvent, e));
|
|
358
|
+
this.internalSubscriptions.forEach(callback => callback(innerEvent, e as VerifiedEvent));
|
|
371
359
|
}
|
|
372
360
|
|
|
373
361
|
private subscribeToNostrEvents() {
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { CHAT_MESSAGE_KIND, NostrPublish, NostrSubscribe, Rumor, Unsubscribe } from "./types"
|
|
2
|
+
import { UserRecord } from "./UserRecord"
|
|
3
|
+
import { Invite } from "./Invite"
|
|
4
|
+
import { getPublicKey } from "nostr-tools"
|
|
5
|
+
import { StorageAdapter, InMemoryStorageAdapter } from "./StorageAdapter"
|
|
6
|
+
import { serializeSessionState, deserializeSessionState } from "./utils"
|
|
7
|
+
import { Session } from "./Session"
|
|
8
|
+
|
|
9
|
+
export default class SessionManager {
|
|
10
|
+
private userRecords: Map<string, UserRecord> = new Map()
|
|
11
|
+
private nostrSubscribe: NostrSubscribe
|
|
12
|
+
private nostrPublish: NostrPublish
|
|
13
|
+
private ourIdentityKey: Uint8Array
|
|
14
|
+
private inviteUnsubscribes: Map<string, Unsubscribe> = new Map()
|
|
15
|
+
private deviceId: string
|
|
16
|
+
private invite?: Invite
|
|
17
|
+
private storage: StorageAdapter
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
ourIdentityKey: Uint8Array,
|
|
21
|
+
deviceId: string,
|
|
22
|
+
nostrSubscribe: NostrSubscribe,
|
|
23
|
+
nostrPublish: NostrPublish,
|
|
24
|
+
storage: StorageAdapter = new InMemoryStorageAdapter(),
|
|
25
|
+
) {
|
|
26
|
+
this.userRecords = new Map()
|
|
27
|
+
this.nostrSubscribe = nostrSubscribe
|
|
28
|
+
this.nostrPublish = nostrPublish
|
|
29
|
+
this.ourIdentityKey = ourIdentityKey
|
|
30
|
+
this.deviceId = deviceId
|
|
31
|
+
this.storage = storage
|
|
32
|
+
|
|
33
|
+
// Kick off initialisation in background for backwards compatibility
|
|
34
|
+
// Users that need to wait can call await manager.init()
|
|
35
|
+
this.init()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private _initialised = false
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Perform asynchronous initialisation steps: create (or load) our invite,
|
|
42
|
+
* publish it, hydrate sessions from storage and subscribe to new invites.
|
|
43
|
+
* Can be awaited by callers that need deterministic readiness.
|
|
44
|
+
*/
|
|
45
|
+
public async init(): Promise<void> {
|
|
46
|
+
if (this._initialised) return
|
|
47
|
+
|
|
48
|
+
const ourPublicKey = getPublicKey(this.ourIdentityKey)
|
|
49
|
+
|
|
50
|
+
// 1. Hydrate existing sessions (placeholder for future implementation)
|
|
51
|
+
await this.loadSessions()
|
|
52
|
+
|
|
53
|
+
// 2. Create or load our own invite
|
|
54
|
+
let invite: Invite | undefined
|
|
55
|
+
try {
|
|
56
|
+
const stored = await this.storage.get<string>(`invite/${this.deviceId}`)
|
|
57
|
+
if (stored) {
|
|
58
|
+
invite = Invite.deserialize(stored)
|
|
59
|
+
}
|
|
60
|
+
} catch {/* ignore malformed */}
|
|
61
|
+
|
|
62
|
+
if (!invite) {
|
|
63
|
+
invite = Invite.createNew(ourPublicKey, this.deviceId)
|
|
64
|
+
await this.storage.put(`invite/${this.deviceId}`, invite.serialize()).catch(() => {})
|
|
65
|
+
}
|
|
66
|
+
this.invite = invite
|
|
67
|
+
|
|
68
|
+
// 2b. Listen for acceptances of *our* invite and create sessions
|
|
69
|
+
this.invite.listen(
|
|
70
|
+
this.ourIdentityKey,
|
|
71
|
+
this.nostrSubscribe,
|
|
72
|
+
(session, inviteePubkey) => {
|
|
73
|
+
if (!inviteePubkey) return
|
|
74
|
+
try {
|
|
75
|
+
let userRecord = this.userRecords.get(inviteePubkey)
|
|
76
|
+
if (!userRecord) {
|
|
77
|
+
userRecord = new UserRecord(inviteePubkey, this.nostrSubscribe)
|
|
78
|
+
this.userRecords.set(inviteePubkey, userRecord)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const deviceKey = session.name || 'unknown'
|
|
82
|
+
userRecord.upsertSession(deviceKey, session)
|
|
83
|
+
this.saveSession(inviteePubkey, deviceKey, session)
|
|
84
|
+
|
|
85
|
+
session.onEvent((_event: Rumor) => {
|
|
86
|
+
this.internalSubscriptions.forEach(cb => cb(_event))
|
|
87
|
+
})
|
|
88
|
+
} catch {/* ignore errors */}
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
// 3. Subscribe to our own invites from other devices
|
|
93
|
+
Invite.fromUser(ourPublicKey, this.nostrSubscribe, async (invite) => {
|
|
94
|
+
try {
|
|
95
|
+
const inviteDeviceId = invite['deviceId'] || 'unknown'
|
|
96
|
+
if (!inviteDeviceId || inviteDeviceId === this.deviceId) {
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const existingRecord = this.userRecords.get(ourPublicKey)
|
|
101
|
+
if (existingRecord?.getActiveSessions().some(session => session.name === inviteDeviceId)) {
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { session, event } = await invite.accept(
|
|
106
|
+
this.nostrSubscribe,
|
|
107
|
+
ourPublicKey,
|
|
108
|
+
this.ourIdentityKey
|
|
109
|
+
)
|
|
110
|
+
this.nostrPublish(event)?.catch(() => {})
|
|
111
|
+
|
|
112
|
+
this.saveSession(ourPublicKey, inviteDeviceId, session)
|
|
113
|
+
|
|
114
|
+
let userRecord = this.userRecords.get(ourPublicKey)
|
|
115
|
+
if (!userRecord) {
|
|
116
|
+
userRecord = new UserRecord(ourPublicKey, this.nostrSubscribe)
|
|
117
|
+
this.userRecords.set(ourPublicKey, userRecord)
|
|
118
|
+
}
|
|
119
|
+
const deviceId = invite['deviceId'] || event.id || 'unknown'
|
|
120
|
+
userRecord.upsertSession(deviceId, session)
|
|
121
|
+
this.saveSession(ourPublicKey, deviceId, session)
|
|
122
|
+
|
|
123
|
+
session.onEvent((_event: Rumor) => {
|
|
124
|
+
this.internalSubscriptions.forEach(cb => cb(_event))
|
|
125
|
+
})
|
|
126
|
+
} catch (err) {
|
|
127
|
+
// eslint-disable-next-line no-console
|
|
128
|
+
console.error('Own-invite accept failed', err)
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
this._initialised = true
|
|
133
|
+
await this.nostrPublish(this.invite.getEvent()).catch(() => {})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async loadSessions() {
|
|
137
|
+
const base = 'session/'
|
|
138
|
+
const keys = await this.storage.list(base)
|
|
139
|
+
for (const key of keys) {
|
|
140
|
+
const rest = key.substring(base.length)
|
|
141
|
+
const idx = rest.indexOf('/')
|
|
142
|
+
if (idx === -1) continue
|
|
143
|
+
const ownerPubKey = rest.substring(0, idx)
|
|
144
|
+
const deviceId = rest.substring(idx + 1) || 'unknown'
|
|
145
|
+
|
|
146
|
+
const data = await this.storage.get<string>(key)
|
|
147
|
+
if (!data) continue
|
|
148
|
+
try {
|
|
149
|
+
const state = deserializeSessionState(data)
|
|
150
|
+
const session = new Session(this.nostrSubscribe, state)
|
|
151
|
+
|
|
152
|
+
let userRecord = this.userRecords.get(ownerPubKey)
|
|
153
|
+
if (!userRecord) {
|
|
154
|
+
userRecord = new UserRecord(ownerPubKey, this.nostrSubscribe)
|
|
155
|
+
this.userRecords.set(ownerPubKey, userRecord)
|
|
156
|
+
}
|
|
157
|
+
userRecord.upsertSession(deviceId, session)
|
|
158
|
+
this.saveSession(ownerPubKey, deviceId, session)
|
|
159
|
+
|
|
160
|
+
session.onEvent((_event: Rumor) => {
|
|
161
|
+
this.internalSubscriptions.forEach(cb => cb(_event))
|
|
162
|
+
})
|
|
163
|
+
} catch {
|
|
164
|
+
// corrupted entry — ignore
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async saveSession(ownerPubKey: string, deviceId: string, session: Session) {
|
|
170
|
+
try {
|
|
171
|
+
const key = `session/${ownerPubKey}/${deviceId}`
|
|
172
|
+
await this.storage.put(key, serializeSessionState(session.state))
|
|
173
|
+
} catch {/* ignore */}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
getDeviceId(): string {
|
|
177
|
+
return this.deviceId
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
getInvite(): Invite {
|
|
181
|
+
if (!this.invite) {
|
|
182
|
+
throw new Error("SessionManager not initialised yet")
|
|
183
|
+
}
|
|
184
|
+
return this.invite
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async sendText(recipientIdentityKey: string, text: string) {
|
|
188
|
+
const event = {
|
|
189
|
+
kind: CHAT_MESSAGE_KIND,
|
|
190
|
+
content: text,
|
|
191
|
+
}
|
|
192
|
+
return await this.sendEvent(recipientIdentityKey, event)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async sendEvent(recipientIdentityKey: string, event: Partial<Rumor>) {
|
|
196
|
+
const results = []
|
|
197
|
+
|
|
198
|
+
// Send to recipient's devices
|
|
199
|
+
const userRecord = this.userRecords.get(recipientIdentityKey)
|
|
200
|
+
if (!userRecord) {
|
|
201
|
+
// Listen for invites from recipient and return without throwing; caller
|
|
202
|
+
// can await a subsequent session establishment.
|
|
203
|
+
this.listenToUser(recipientIdentityKey)
|
|
204
|
+
return []
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Send to all active sessions with recipient
|
|
208
|
+
for (const session of userRecord.getActiveSessions()) {
|
|
209
|
+
const { event: encryptedEvent } = session.sendEvent(event)
|
|
210
|
+
results.push(encryptedEvent)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Send to our own devices (for multi-device sync)
|
|
214
|
+
const ourPublicKey = getPublicKey(this.ourIdentityKey)
|
|
215
|
+
const ownUserRecord = this.userRecords.get(ourPublicKey)
|
|
216
|
+
if (ownUserRecord) {
|
|
217
|
+
for (const session of ownUserRecord.getActiveSessions()) {
|
|
218
|
+
const { event: encryptedEvent } = session.sendEvent(event)
|
|
219
|
+
results.push(encryptedEvent)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return results
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
listenToUser(userPubkey: string) {
|
|
227
|
+
// Don't subscribe multiple times to the same user
|
|
228
|
+
if (this.inviteUnsubscribes.has(userPubkey)) return
|
|
229
|
+
|
|
230
|
+
const unsubscribe = Invite.fromUser(userPubkey, this.nostrSubscribe, async (_invite) => {
|
|
231
|
+
try {
|
|
232
|
+
const { session, event } = await _invite.accept(
|
|
233
|
+
this.nostrSubscribe,
|
|
234
|
+
getPublicKey(this.ourIdentityKey),
|
|
235
|
+
this.ourIdentityKey
|
|
236
|
+
)
|
|
237
|
+
this.nostrPublish(event)?.catch(() => {})
|
|
238
|
+
|
|
239
|
+
// Store the new session
|
|
240
|
+
let userRecord = this.userRecords.get(userPubkey)
|
|
241
|
+
if (!userRecord) {
|
|
242
|
+
userRecord = new UserRecord(userPubkey, this.nostrSubscribe)
|
|
243
|
+
this.userRecords.set(userPubkey, userRecord)
|
|
244
|
+
}
|
|
245
|
+
const deviceId = (_invite instanceof Invite && _invite.deviceId) ? _invite.deviceId : event.id || 'unknown'
|
|
246
|
+
this.saveSession(userPubkey, deviceId, session)
|
|
247
|
+
|
|
248
|
+
// Set up event handling for the new session
|
|
249
|
+
session.onEvent((_event: Rumor) => {
|
|
250
|
+
this.internalSubscriptions.forEach(callback => callback(_event))
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
// Return the event to be published
|
|
254
|
+
return event
|
|
255
|
+
} catch {
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
this.inviteUnsubscribes.set(userPubkey, unsubscribe)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
stopListeningToUser(userPubkey: string) {
|
|
263
|
+
const unsubscribe = this.inviteUnsubscribes.get(userPubkey)
|
|
264
|
+
if (unsubscribe) {
|
|
265
|
+
unsubscribe()
|
|
266
|
+
this.inviteUnsubscribes.delete(userPubkey)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Update onEvent to include internalSubscriptions management
|
|
271
|
+
private internalSubscriptions: Set<(event: Rumor) => void> = new Set()
|
|
272
|
+
|
|
273
|
+
onEvent(callback: (event: Rumor) => void) {
|
|
274
|
+
this.internalSubscriptions.add(callback)
|
|
275
|
+
|
|
276
|
+
// Subscribe to existing sessions
|
|
277
|
+
for (const userRecord of this.userRecords.values()) {
|
|
278
|
+
for (const session of userRecord.getActiveSessions()) {
|
|
279
|
+
session.onEvent((_event: Rumor) => {
|
|
280
|
+
callback(_event)
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Return unsubscribe function
|
|
286
|
+
return () => {
|
|
287
|
+
this.internalSubscriptions.delete(callback)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
close() {
|
|
292
|
+
// Clean up all subscriptions
|
|
293
|
+
for (const unsubscribe of this.inviteUnsubscribes.values()) {
|
|
294
|
+
unsubscribe()
|
|
295
|
+
}
|
|
296
|
+
this.inviteUnsubscribes.clear()
|
|
297
|
+
|
|
298
|
+
// Close all sessions
|
|
299
|
+
for (const userRecord of this.userRecords.values()) {
|
|
300
|
+
for (const session of userRecord.getActiveSessions()) {
|
|
301
|
+
session.close()
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
this.userRecords.clear()
|
|
305
|
+
this.internalSubscriptions.clear()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Accept an invite as our own device, persist the session, and publish the acceptance event.
|
|
310
|
+
* Used for multi-device flows where a user adds a new device.
|
|
311
|
+
*/
|
|
312
|
+
public async acceptOwnInvite(invite: Invite) {
|
|
313
|
+
const ourPublicKey = getPublicKey(this.ourIdentityKey);
|
|
314
|
+
const { session, event } = await invite.accept(
|
|
315
|
+
this.nostrSubscribe,
|
|
316
|
+
ourPublicKey,
|
|
317
|
+
this.ourIdentityKey
|
|
318
|
+
);
|
|
319
|
+
let userRecord = this.userRecords.get(ourPublicKey);
|
|
320
|
+
if (!userRecord) {
|
|
321
|
+
userRecord = new UserRecord(ourPublicKey, this.nostrSubscribe);
|
|
322
|
+
this.userRecords.set(ourPublicKey, userRecord);
|
|
323
|
+
}
|
|
324
|
+
userRecord.upsertSession(session.name || 'unknown', session);
|
|
325
|
+
await this.saveSession(ourPublicKey, session.name || 'unknown', session);
|
|
326
|
+
this.nostrPublish(event)?.catch(() => {});
|
|
327
|
+
}
|
|
328
|
+
}
|