nostr-double-ratchet 0.0.26 → 0.0.28
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/dist/Invite.d.ts +3 -3
- package/dist/Invite.d.ts.map +1 -1
- package/dist/Session.d.ts +1 -1
- package/dist/Session.d.ts.map +1 -1
- package/dist/SessionManager.d.ts +23 -0
- package/dist/SessionManager.d.ts.map +1 -0
- package/dist/UserRecord.d.ts +30 -3
- package/dist/UserRecord.d.ts.map +1 -1
- package/dist/nostr-double-ratchet.es.js +1285 -1194
- package/dist/nostr-double-ratchet.umd.js +1 -1
- package/dist/types.d.ts +5 -5
- 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 +14 -14
- package/src/Session.ts +17 -26
- package/src/SessionManager.ts +165 -0
- package/src/UserRecord.ts +64 -6
- package/src/types.ts +5 -5
- 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.28",
|
|
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.13.0",
|
|
56
|
+
"react-blurhash": "^0.3.0"
|
|
55
57
|
}
|
|
56
58
|
}
|
package/src/Invite.ts
CHANGED
|
@@ -28,7 +28,8 @@ export class Invite {
|
|
|
28
28
|
public label?: string,
|
|
29
29
|
public maxUses?: number,
|
|
30
30
|
public usedBy: string[] = [],
|
|
31
|
-
) {
|
|
31
|
+
) {
|
|
32
|
+
}
|
|
32
33
|
|
|
33
34
|
static createNew(inviter: string, label?: string, maxUses?: number): Invite {
|
|
34
35
|
if (!inviter) {
|
|
@@ -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) {
|
|
@@ -115,12 +116,11 @@ export class Invite {
|
|
|
115
116
|
);
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
static fromUser(user: string, subscribe: NostrSubscribe, onInvite: (
|
|
119
|
+
static fromUser(user: string, subscribe: NostrSubscribe, onInvite: (_invite: Invite) => void): Unsubscribe {
|
|
119
120
|
const filter: Filter = {
|
|
120
121
|
kinds: [INVITE_EVENT_KIND],
|
|
121
122
|
authors: [user],
|
|
122
|
-
|
|
123
|
-
"#d": ["double-ratchet/invites/public"],
|
|
123
|
+
"#l": ["double-ratchet/invites"]
|
|
124
124
|
};
|
|
125
125
|
let latest = 0;
|
|
126
126
|
const unsub = subscribe(filter, (event) => {
|
|
@@ -131,8 +131,7 @@ export class Invite {
|
|
|
131
131
|
try {
|
|
132
132
|
const inviteLink = Invite.fromEvent(event);
|
|
133
133
|
onInvite(inviteLink);
|
|
134
|
-
} catch
|
|
135
|
-
console.error("Error processing invite:", error, "event:", event);
|
|
134
|
+
} catch {
|
|
136
135
|
}
|
|
137
136
|
});
|
|
138
137
|
|
|
@@ -168,7 +167,10 @@ export class Invite {
|
|
|
168
167
|
return url.toString();
|
|
169
168
|
}
|
|
170
169
|
|
|
171
|
-
getEvent(): UnsignedEvent {
|
|
170
|
+
getEvent(deviceName: string): UnsignedEvent {
|
|
171
|
+
if (!deviceName) {
|
|
172
|
+
throw new Error("Device name is required");
|
|
173
|
+
}
|
|
172
174
|
return {
|
|
173
175
|
kind: INVITE_EVENT_KIND,
|
|
174
176
|
pubkey: this.inviter,
|
|
@@ -177,7 +179,7 @@ export class Invite {
|
|
|
177
179
|
tags: [
|
|
178
180
|
['ephemeralKey', this.inviterEphemeralPublicKey],
|
|
179
181
|
['sharedSecret', this.sharedSecret],
|
|
180
|
-
['d', 'double-ratchet/invites/
|
|
182
|
+
['d', 'double-ratchet/invites/' + deviceName],
|
|
181
183
|
['l', 'double-ratchet/invites']
|
|
182
184
|
],
|
|
183
185
|
};
|
|
@@ -240,7 +242,7 @@ export class Invite {
|
|
|
240
242
|
return { session, event: finalizeEvent(envelope, randomSenderKey) };
|
|
241
243
|
}
|
|
242
244
|
|
|
243
|
-
listen(decryptor: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (
|
|
245
|
+
listen(decryptor: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (_session: Session, _identity?: string) => void): Unsubscribe {
|
|
244
246
|
if (!this.inviterEphemeralPrivateKey) {
|
|
245
247
|
throw new Error("Inviter session key is not available");
|
|
246
248
|
}
|
|
@@ -253,7 +255,6 @@ export class Invite {
|
|
|
253
255
|
return nostrSubscribe(filter, async (event) => {
|
|
254
256
|
try {
|
|
255
257
|
if (this.maxUses && this.usedBy.length >= this.maxUses) {
|
|
256
|
-
console.error("Invite has reached maximum number of uses");
|
|
257
258
|
return;
|
|
258
259
|
}
|
|
259
260
|
|
|
@@ -279,9 +280,8 @@ export class Invite {
|
|
|
279
280
|
const session = Session.init(nostrSubscribe, inviteeSessionPublicKey, this.inviterEphemeralPrivateKey!, false, sharedSecret, name);
|
|
280
281
|
|
|
281
282
|
onSession(session, inviteeIdentity);
|
|
282
|
-
} catch
|
|
283
|
-
console.error("Error processing invite message:", error, "event", event);
|
|
283
|
+
} catch {
|
|
284
284
|
}
|
|
285
285
|
});
|
|
286
286
|
}
|
|
287
|
-
}
|
|
287
|
+
}
|
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,7 +38,7 @@ export class Session {
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Initializes a new secure communication session
|
|
41
|
-
* @param nostrSubscribe Function to subscribe to Nostr events
|
|
41
|
+
* @param nostrSubscribe Function to subscribe to Nostr events. Make sure it deduplicates events (doesnt return the same event twice), otherwise you'll see decryption errors!
|
|
42
42
|
* @param theirNextNostrPublicKey The public key of the other party
|
|
43
43
|
* @param ourCurrentPrivateKey Our current private key for Nostr
|
|
44
44
|
* @param isInitiator Whether we are initiating the conversation (true) or responding (false)
|
|
@@ -205,24 +205,13 @@ 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
|
-
private ratchetStep(
|
|
211
|
+
private ratchetStep() {
|
|
222
212
|
this.state.previousSendingChainMessageCount = this.state.sendingChainMessageNumber;
|
|
223
213
|
this.state.sendingChainMessageNumber = 0;
|
|
224
214
|
this.state.receivingChainMessageNumber = 0;
|
|
225
|
-
this.state.theirNextNostrPublicKey = theirNextNostrPublicKey;
|
|
226
215
|
|
|
227
216
|
const conversationKey1 = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, this.state.theirNextNostrPublicKey!);
|
|
228
217
|
const [theirRootKey, receivingChainKey] = kdf(this.state.rootKey, conversationKey1, 2);
|
|
@@ -259,10 +248,14 @@ export class Session {
|
|
|
259
248
|
// Store header keys
|
|
260
249
|
if (this.state.ourCurrentNostrKey) {
|
|
261
250
|
const currentSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, nostrSender);
|
|
262
|
-
this.state.skippedKeys[nostrSender].headerKeys.
|
|
251
|
+
if (!this.state.skippedKeys[nostrSender].headerKeys.includes(currentSecret)) {
|
|
252
|
+
this.state.skippedKeys[nostrSender].headerKeys.push(currentSecret);
|
|
253
|
+
}
|
|
263
254
|
}
|
|
264
255
|
const nextSecret = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, nostrSender);
|
|
265
|
-
this.state.skippedKeys[nostrSender].headerKeys.
|
|
256
|
+
if (!this.state.skippedKeys[nostrSender].headerKeys.includes(nextSecret)) {
|
|
257
|
+
this.state.skippedKeys[nostrSender].headerKeys.push(nextSecret);
|
|
258
|
+
}
|
|
266
259
|
}
|
|
267
260
|
|
|
268
261
|
while (this.state.receivingChainMessageNumber < until) {
|
|
@@ -291,14 +284,14 @@ export class Session {
|
|
|
291
284
|
}
|
|
292
285
|
|
|
293
286
|
// 4. NOSTR EVENT HANDLING
|
|
294
|
-
private decryptHeader(event:
|
|
287
|
+
private decryptHeader(event: { tags: string[][]; pubkey: string }): [Header, boolean, boolean] {
|
|
295
288
|
const encryptedHeader = event.tags[0][1];
|
|
296
289
|
if (this.state.ourCurrentNostrKey) {
|
|
297
290
|
const currentSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, event.pubkey);
|
|
298
291
|
try {
|
|
299
292
|
const header = JSON.parse(nip44.decrypt(encryptedHeader, currentSecret)) as Header;
|
|
300
293
|
return [header, false, false];
|
|
301
|
-
} catch
|
|
294
|
+
} catch {
|
|
302
295
|
// Decryption with currentSecret failed, try with nextSecret
|
|
303
296
|
}
|
|
304
297
|
}
|
|
@@ -307,7 +300,7 @@ export class Session {
|
|
|
307
300
|
try {
|
|
308
301
|
const header = JSON.parse(nip44.decrypt(encryptedHeader, nextSecret)) as Header;
|
|
309
302
|
return [header, true, false];
|
|
310
|
-
} catch
|
|
303
|
+
} catch {
|
|
311
304
|
// Decryption with nextSecret also failed
|
|
312
305
|
}
|
|
313
306
|
|
|
@@ -317,7 +310,7 @@ export class Session {
|
|
|
317
310
|
try {
|
|
318
311
|
const header = JSON.parse(nip44.decrypt(encryptedHeader, key)) as Header;
|
|
319
312
|
return [header, false, true];
|
|
320
|
-
} catch
|
|
313
|
+
} catch {
|
|
321
314
|
// Decryption failed, try next secret
|
|
322
315
|
}
|
|
323
316
|
}
|
|
@@ -326,7 +319,7 @@ export class Session {
|
|
|
326
319
|
throw new Error("Failed to decrypt header with current and skipped header keys");
|
|
327
320
|
}
|
|
328
321
|
|
|
329
|
-
private handleNostrEvent(e:
|
|
322
|
+
private handleNostrEvent(e: { tags: string[][]; pubkey: string; content: string }) {
|
|
330
323
|
const [header, shouldRatchet, isSkipped] = this.decryptHeader(e);
|
|
331
324
|
|
|
332
325
|
if (!isSkipped) {
|
|
@@ -343,7 +336,7 @@ export class Session {
|
|
|
343
336
|
|
|
344
337
|
if (shouldRatchet) {
|
|
345
338
|
this.skipMessageKeys(header.previousChainLength, e.pubkey);
|
|
346
|
-
this.ratchetStep(
|
|
339
|
+
this.ratchetStep();
|
|
347
340
|
}
|
|
348
341
|
} else {
|
|
349
342
|
if (!this.state.skippedKeys[e.pubkey]?.messageKeys[header.number]) {
|
|
@@ -355,16 +348,14 @@ export class Session {
|
|
|
355
348
|
const text = this.ratchetDecrypt(header, e.content, e.pubkey);
|
|
356
349
|
const innerEvent = JSON.parse(text);
|
|
357
350
|
if (!validateEvent(innerEvent)) {
|
|
358
|
-
console.error("Invalid event received", innerEvent);
|
|
359
351
|
return;
|
|
360
352
|
}
|
|
361
353
|
|
|
362
354
|
if (innerEvent.id !== getEventHash(innerEvent)) {
|
|
363
|
-
console.error("Event hash does not match", innerEvent);
|
|
364
355
|
return;
|
|
365
356
|
}
|
|
366
357
|
|
|
367
|
-
this.internalSubscriptions.forEach(callback => callback(innerEvent, e));
|
|
358
|
+
this.internalSubscriptions.forEach(callback => callback(innerEvent, e as VerifiedEvent));
|
|
368
359
|
}
|
|
369
360
|
|
|
370
361
|
private subscribeToNostrEvents() {
|
|
@@ -0,0 +1,165 @@
|
|
|
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
|
+
|
|
6
|
+
export default class SessionManager {
|
|
7
|
+
private userRecords: Map<string, UserRecord> = new Map()
|
|
8
|
+
private nostrSubscribe: NostrSubscribe
|
|
9
|
+
private nostrPublish: NostrPublish
|
|
10
|
+
private ourIdentityKey: Uint8Array
|
|
11
|
+
private inviteUnsubscribes: Map<string, Unsubscribe> = new Map()
|
|
12
|
+
private ownDeviceInvites: Map<string, Invite | null> = new Map()
|
|
13
|
+
|
|
14
|
+
constructor(ourIdentityKey: Uint8Array, nostrSubscribe: NostrSubscribe, nostrPublish: NostrPublish) {
|
|
15
|
+
this.userRecords = new Map()
|
|
16
|
+
this.nostrSubscribe = nostrSubscribe
|
|
17
|
+
this.nostrPublish = nostrPublish
|
|
18
|
+
this.ourIdentityKey = ourIdentityKey
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async sendText(recipientIdentityKey: string, text: string) {
|
|
22
|
+
const event = {
|
|
23
|
+
kind: CHAT_MESSAGE_KIND,
|
|
24
|
+
content: text,
|
|
25
|
+
}
|
|
26
|
+
return await this.sendEvent(recipientIdentityKey, event)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async sendEvent(recipientIdentityKey: string, event: Partial<Rumor>) {
|
|
30
|
+
const results = []
|
|
31
|
+
|
|
32
|
+
// Send to recipient's devices
|
|
33
|
+
const userRecord = this.userRecords.get(recipientIdentityKey)
|
|
34
|
+
if (!userRecord) {
|
|
35
|
+
// Listen for invites from recipient
|
|
36
|
+
this.listenToUser(recipientIdentityKey)
|
|
37
|
+
throw new Error("No active session with user. Listening for invites.")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Send to all active sessions with recipient
|
|
41
|
+
for (const session of userRecord.getActiveSessions()) {
|
|
42
|
+
const { event: encryptedEvent } = session.sendEvent(event)
|
|
43
|
+
results.push(encryptedEvent)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Send to our own devices (for multi-device sync)
|
|
47
|
+
const ourPublicKey = getPublicKey(this.ourIdentityKey)
|
|
48
|
+
const ownUserRecord = this.userRecords.get(ourPublicKey)
|
|
49
|
+
if (ownUserRecord) {
|
|
50
|
+
for (const session of ownUserRecord.getActiveSessions()) {
|
|
51
|
+
const { event: encryptedEvent } = session.sendEvent(event)
|
|
52
|
+
results.push(encryptedEvent)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return results
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
listenToUser(userPubkey: string) {
|
|
60
|
+
// Don't subscribe multiple times to the same user
|
|
61
|
+
if (this.inviteUnsubscribes.has(userPubkey)) return
|
|
62
|
+
|
|
63
|
+
const unsubscribe = Invite.fromUser(userPubkey, this.nostrSubscribe, async (_invite) => {
|
|
64
|
+
try {
|
|
65
|
+
const { session, event } = await _invite.accept(
|
|
66
|
+
this.nostrSubscribe,
|
|
67
|
+
getPublicKey(this.ourIdentityKey),
|
|
68
|
+
this.ourIdentityKey
|
|
69
|
+
)
|
|
70
|
+
this.nostrPublish(event)
|
|
71
|
+
|
|
72
|
+
// Store the new session
|
|
73
|
+
let userRecord = this.userRecords.get(userPubkey)
|
|
74
|
+
if (!userRecord) {
|
|
75
|
+
userRecord = new UserRecord(userPubkey, this.nostrSubscribe)
|
|
76
|
+
this.userRecords.set(userPubkey, userRecord)
|
|
77
|
+
}
|
|
78
|
+
userRecord.insertSession(event.id || 'unknown', session)
|
|
79
|
+
|
|
80
|
+
// Set up event handling for the new session
|
|
81
|
+
session.onEvent((_event) => {
|
|
82
|
+
this.internalSubscriptions.forEach(callback => callback(_event))
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// Return the event to be published
|
|
86
|
+
return event
|
|
87
|
+
} catch {
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
this.inviteUnsubscribes.set(userPubkey, unsubscribe)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
stopListeningToUser(userPubkey: string) {
|
|
95
|
+
const unsubscribe = this.inviteUnsubscribes.get(userPubkey)
|
|
96
|
+
if (unsubscribe) {
|
|
97
|
+
unsubscribe()
|
|
98
|
+
this.inviteUnsubscribes.delete(userPubkey)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Update onEvent to include internalSubscriptions management
|
|
103
|
+
private internalSubscriptions: Set<(_event: Rumor) => void> = new Set()
|
|
104
|
+
|
|
105
|
+
onEvent(callback: (_event: Rumor) => void) {
|
|
106
|
+
this.internalSubscriptions.add(callback)
|
|
107
|
+
|
|
108
|
+
// Subscribe to existing sessions
|
|
109
|
+
for (const userRecord of this.userRecords.values()) {
|
|
110
|
+
for (const session of userRecord.getActiveSessions()) {
|
|
111
|
+
session.onEvent((_event: Rumor) => {
|
|
112
|
+
callback(_event)
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Return unsubscribe function
|
|
118
|
+
return () => {
|
|
119
|
+
this.internalSubscriptions.delete(callback)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
close() {
|
|
124
|
+
// Clean up all subscriptions
|
|
125
|
+
for (const unsubscribe of this.inviteUnsubscribes.values()) {
|
|
126
|
+
unsubscribe()
|
|
127
|
+
}
|
|
128
|
+
this.inviteUnsubscribes.clear()
|
|
129
|
+
|
|
130
|
+
// Close all sessions
|
|
131
|
+
for (const userRecord of this.userRecords.values()) {
|
|
132
|
+
for (const session of userRecord.getActiveSessions()) {
|
|
133
|
+
session.close()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
this.userRecords.clear()
|
|
137
|
+
this.internalSubscriptions.clear()
|
|
138
|
+
this.ownDeviceInvites.clear()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
createOwnDeviceInvite(deviceName: string, label?: string, maxUses?: number): Invite {
|
|
142
|
+
const ourPublicKey = getPublicKey(this.ourIdentityKey)
|
|
143
|
+
const invite = Invite.createNew(ourPublicKey, label, maxUses)
|
|
144
|
+
this.ownDeviceInvites.set(deviceName, invite)
|
|
145
|
+
return invite
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
removeOwnDevice(deviceName: string): void {
|
|
149
|
+
this.ownDeviceInvites.set(deviceName, null)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
getOwnDeviceInvites(): Map<string, Invite | null> {
|
|
153
|
+
return new Map(this.ownDeviceInvites)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
getActiveOwnDeviceInvites(): Map<string, Invite> {
|
|
157
|
+
const active = new Map<string, Invite>()
|
|
158
|
+
for (const [deviceName, invite] of this.ownDeviceInvites) {
|
|
159
|
+
if (invite !== null) {
|
|
160
|
+
active.set(deviceName, invite)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return active
|
|
164
|
+
}
|
|
165
|
+
}
|
package/src/UserRecord.ts
CHANGED
|
@@ -17,11 +17,21 @@ export class UserRecord {
|
|
|
17
17
|
private deviceRecords: Map<string, DeviceRecord> = new Map();
|
|
18
18
|
private isStale: boolean = false;
|
|
19
19
|
private staleTimestamp?: number;
|
|
20
|
+
/**
|
|
21
|
+
* Temporary store for sessions when the corresponding deviceId is unknown.
|
|
22
|
+
*
|
|
23
|
+
* SessionManager currently operates at a per-user granularity (it is not
|
|
24
|
+
* yet aware of individual devices). Until full Sesame device handling is
|
|
25
|
+
* implemented we keep sessions in this simple list so that
|
|
26
|
+
* SessionManager.getActiveSessions / getAllSessions work as expected.
|
|
27
|
+
*/
|
|
28
|
+
private extraSessions: Session[] = [];
|
|
20
29
|
|
|
21
30
|
constructor(
|
|
22
|
-
public
|
|
23
|
-
private
|
|
24
|
-
) {
|
|
31
|
+
public _userId: string,
|
|
32
|
+
private _nostrSubscribe: NostrSubscribe,
|
|
33
|
+
) {
|
|
34
|
+
}
|
|
25
35
|
|
|
26
36
|
/**
|
|
27
37
|
* Adds or updates a device record for this user
|
|
@@ -109,7 +119,7 @@ export class UserRecord {
|
|
|
109
119
|
if (this.isStale) return [];
|
|
110
120
|
|
|
111
121
|
return Array.from(this.deviceRecords.entries())
|
|
112
|
-
.filter(([
|
|
122
|
+
.filter(([, record]) => !record.isStale && record.activeSession)
|
|
113
123
|
.map(([deviceId, record]) => [deviceId, record.activeSession!]);
|
|
114
124
|
}
|
|
115
125
|
|
|
@@ -129,7 +139,7 @@ export class UserRecord {
|
|
|
129
139
|
}
|
|
130
140
|
|
|
131
141
|
const session = Session.init(
|
|
132
|
-
this.
|
|
142
|
+
this._nostrSubscribe,
|
|
133
143
|
record.publicKey,
|
|
134
144
|
ourCurrentPrivateKey,
|
|
135
145
|
isInitiator,
|
|
@@ -179,4 +189,52 @@ export class UserRecord {
|
|
|
179
189
|
});
|
|
180
190
|
this.deviceRecords.clear();
|
|
181
191
|
}
|
|
182
|
-
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Helper methods used by SessionManager (WIP):
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Add a session without associating it with a specific device.
|
|
199
|
+
* This is mainly used by SessionManager which does not yet keep track of
|
|
200
|
+
* device identifiers. The session will be considered active.
|
|
201
|
+
*/
|
|
202
|
+
public addSession(session: Session): void {
|
|
203
|
+
this.extraSessions.push(session);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Return all sessions that are currently considered *active*.
|
|
208
|
+
* For now this means any session in a non-stale device record as well as
|
|
209
|
+
* all sessions added through `addSession`.
|
|
210
|
+
*/
|
|
211
|
+
public getActiveSessions(): Session[] {
|
|
212
|
+
const sessions: Session[] = [...this.extraSessions];
|
|
213
|
+
|
|
214
|
+
for (const record of this.deviceRecords.values()) {
|
|
215
|
+
if (!record.isStale && record.activeSession) {
|
|
216
|
+
sessions.push(record.activeSession);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return sessions;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Return *all* sessions — active or inactive — that we have stored for this
|
|
225
|
+
* user. This is required for `SessionManager.onEvent` so that it can attach
|
|
226
|
+
* listeners to existing sessions.
|
|
227
|
+
*/
|
|
228
|
+
public getAllSessions(): Session[] {
|
|
229
|
+
const sessions: Session[] = [...this.extraSessions];
|
|
230
|
+
|
|
231
|
+
for (const record of this.deviceRecords.values()) {
|
|
232
|
+
if (record.activeSession) {
|
|
233
|
+
sessions.push(record.activeSession);
|
|
234
|
+
}
|
|
235
|
+
sessions.push(...record.inactiveSessions);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return sessions;
|
|
239
|
+
}
|
|
240
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -65,10 +65,10 @@ export type Unsubscribe = () => void;
|
|
|
65
65
|
/**
|
|
66
66
|
* Function that subscribes to Nostr events matching a filter and calls onEvent for each event.
|
|
67
67
|
*/
|
|
68
|
-
export type NostrSubscribe = (
|
|
69
|
-
export type EncryptFunction = (
|
|
70
|
-
export type DecryptFunction = (
|
|
71
|
-
export type NostrPublish = (
|
|
68
|
+
export type NostrSubscribe = (_filter: Filter, _onEvent: (_e: VerifiedEvent) => void) => Unsubscribe;
|
|
69
|
+
export type EncryptFunction = (_plaintext: string, _pubkey: string) => Promise<string>;
|
|
70
|
+
export type DecryptFunction = (_ciphertext: string, _pubkey: string) => Promise<string>;
|
|
71
|
+
export type NostrPublish = (_event: UnsignedEvent) => Promise<VerifiedEvent>;
|
|
72
72
|
|
|
73
73
|
export type Rumor = UnsignedEvent & { id: string }
|
|
74
74
|
|
|
@@ -76,7 +76,7 @@ export type Rumor = UnsignedEvent & { id: string }
|
|
|
76
76
|
* Callback function for handling decrypted messages
|
|
77
77
|
* @param message - The decrypted message object
|
|
78
78
|
*/
|
|
79
|
-
export type EventCallback = (
|
|
79
|
+
export type EventCallback = (_event: Rumor, _outerEvent: VerifiedEvent) => void;
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
82
|
* Message event kind
|
package/src/utils.ts
CHANGED
|
@@ -51,10 +51,10 @@ export function deserializeSessionState(data: string): SessionState {
|
|
|
51
51
|
|
|
52
52
|
// Migrate old skipped keys format to new structure
|
|
53
53
|
if (state.skippedMessageKeys) {
|
|
54
|
-
Object.entries(state.skippedMessageKeys).forEach(([pubKey, messageKeys]: [string,
|
|
54
|
+
Object.entries(state.skippedMessageKeys).forEach(([pubKey, messageKeys]: [string, unknown]) => {
|
|
55
55
|
skippedKeys[pubKey] = {
|
|
56
56
|
headerKeys: state.skippedHeaderKeys?.[pubKey] || [],
|
|
57
|
-
messageKeys: messageKeys
|
|
57
|
+
messageKeys: messageKeys as { [msgIndex: number]: Uint8Array }
|
|
58
58
|
};
|
|
59
59
|
});
|
|
60
60
|
}
|
|
@@ -102,9 +102,9 @@ export function deserializeSessionState(data: string): SessionState {
|
|
|
102
102
|
Object.entries(state.skippedKeys || {}).map(([pubKey, value]) => [
|
|
103
103
|
pubKey,
|
|
104
104
|
{
|
|
105
|
-
headerKeys: (value as
|
|
105
|
+
headerKeys: (value as { headerKeys: string[] }).headerKeys.map((hex: string) => hexToBytes(hex)),
|
|
106
106
|
messageKeys: Object.fromEntries(
|
|
107
|
-
Object.entries((value as
|
|
107
|
+
Object.entries((value as { messageKeys: Record<string, string> }).messageKeys).map(([msgIndex, hex]) => [
|
|
108
108
|
msgIndex,
|
|
109
109
|
hexToBytes(hex as string)
|
|
110
110
|
])
|
|
@@ -117,14 +117,14 @@ export function deserializeSessionState(data: string): SessionState {
|
|
|
117
117
|
|
|
118
118
|
export async function* createEventStream(session: Session): AsyncGenerator<Rumor, void, unknown> {
|
|
119
119
|
const messageQueue: Rumor[] = [];
|
|
120
|
-
let resolveNext: ((
|
|
120
|
+
let resolveNext: ((_value: Rumor) => void) | null = null;
|
|
121
121
|
|
|
122
|
-
const unsubscribe = session.onEvent((
|
|
122
|
+
const unsubscribe = session.onEvent((_event) => {
|
|
123
123
|
if (resolveNext) {
|
|
124
|
-
resolveNext(
|
|
124
|
+
resolveNext(_event);
|
|
125
125
|
resolveNext = null;
|
|
126
126
|
} else {
|
|
127
|
-
messageQueue.push(
|
|
127
|
+
messageQueue.push(_event);
|
|
128
128
|
}
|
|
129
129
|
});
|
|
130
130
|
|
|
@@ -153,14 +153,14 @@ export function kdf(input1: Uint8Array, input2: Uint8Array = new Uint8Array(32),
|
|
|
153
153
|
return outputs;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
export function skippedMessageIndexKey(
|
|
157
|
-
return `${
|
|
156
|
+
export function skippedMessageIndexKey(_nostrSender: string, _number: number): string {
|
|
157
|
+
return `${_nostrSender}:${_number}`;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
export function getMillisecondTimestamp(event: Rumor) {
|
|
161
|
-
const msTag = event.tags?.find(tag => tag[0] === "ms");
|
|
161
|
+
const msTag = event.tags?.find((tag: string[]) => tag[0] === "ms");
|
|
162
162
|
if (msTag) {
|
|
163
163
|
return parseInt(msTag[1]);
|
|
164
164
|
}
|
|
165
165
|
return event.created_at * 1000;
|
|
166
|
-
}
|
|
166
|
+
}
|