nostr-double-ratchet 0.0.27 → 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 +1275 -1184
- 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 +9 -21
- 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,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,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
|
+
}
|