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/dist/Invite.d.ts +5 -6
- package/dist/Invite.d.ts.map +1 -1
- package/dist/Session.d.ts +15 -8
- package/dist/Session.d.ts.map +1 -1
- package/dist/UserRecord.d.ts +50 -0
- package/dist/UserRecord.d.ts.map +1 -1
- package/dist/nostr-double-ratchet.es.js +1226 -1203
- package/dist/nostr-double-ratchet.umd.js +1 -1
- package/dist/types.d.ts +15 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +2 -2
- package/dist/utils.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/Invite.ts +40 -34
- package/src/Session.ts +72 -33
- package/src/UserRecord.ts +182 -0
- package/src/types.ts +18 -19
- package/src/utils.ts +12 -10
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
|
|
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
|
|
34
|
+
const sharedSecret = bytesToHex(generateSecretKey());
|
|
37
35
|
return new Invite(
|
|
38
36
|
inviterEphemeralPublicKey,
|
|
39
|
-
|
|
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,
|
|
63
|
-
if (!inviter || !ephemeralKey || !
|
|
64
|
-
throw new Error("Missing required fields (inviter, ephemeralKey,
|
|
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
|
-
|
|
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.
|
|
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
|
|
94
|
+
const sharedSecret = tags.find(([key]) => key === 'sharedSecret')?.[1];
|
|
97
95
|
const inviter = event.pubkey;
|
|
98
96
|
|
|
99
|
-
if (!inviterEphemeralPublicKey || !
|
|
100
|
-
throw new Error("Invalid invite event: missing session key or
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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], ['
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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: [['
|
|
212
|
-
content: await encrypt(
|
|
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(
|
|
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 === '
|
|
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
|
|
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
|
-
|
|
260
|
-
const
|
|
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
74
|
-
* @param
|
|
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
|
-
|
|
79
|
-
if (!this.state.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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.
|
|
194
|
+
this.state.theirNextNostrPublicKey = theirNextNostrPublicKey;
|
|
159
195
|
|
|
160
|
-
const conversationKey1 = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, this.state.
|
|
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.
|
|
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.
|
|
265
|
-
this.state.
|
|
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.
|
|
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
|
|
322
|
+
const text = this.ratchetDecrypt(header, e.content, e.pubkey);
|
|
323
|
+
const innerEvent = JSON.parse(text);
|
|
286
324
|
|
|
287
|
-
this.internalSubscriptions.forEach(callback => callback(
|
|
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.
|
|
331
|
+
{authors: [this.state.theirNextNostrPublicKey], kinds: [MESSAGE_EVENT_KIND]},
|
|
294
332
|
(e) => this.handleNostrEvent(e)
|
|
295
333
|
);
|
|
296
334
|
|
|
297
|
-
const
|
|
298
|
-
if (
|
|
299
|
-
|
|
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
|
-
|
|
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
|
|
74
|
+
export type EventCallback = (event: Rumor, outerEvent: VerifiedEvent) => void;
|
|
81
75
|
|
|
82
|
-
|
|
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>;
|