nostr-double-ratchet 0.0.12 → 0.0.14
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 +7 -8
- package/dist/Invite.d.ts.map +1 -1
- package/dist/Session.d.ts +2 -2
- package/dist/Session.d.ts.map +1 -1
- package/dist/UserRecord.d.ts +51 -0
- package/dist/UserRecord.d.ts.map +1 -0
- package/dist/nostr-double-ratchet.es.js +333 -331
- package/dist/nostr-double-ratchet.umd.js +1 -1
- package/dist/types.d.ts +3 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/Invite.ts +62 -56
- package/src/Session.ts +24 -22
- package/src/UserRecord.ts +182 -0
- package/src/types.ts +4 -1
- package/src/utils.ts +4 -2
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
|
/**
|
|
@@ -18,10 +16,10 @@ import { hexToBytes, bytesToHex } from "@noble/hashes/utils";
|
|
|
18
16
|
*/
|
|
19
17
|
export class Invite {
|
|
20
18
|
constructor(
|
|
21
|
-
public
|
|
22
|
-
public
|
|
19
|
+
public inviterEphemeralPublicKey: string,
|
|
20
|
+
public sharedSecret: string,
|
|
23
21
|
public inviter: string,
|
|
24
|
-
public
|
|
22
|
+
public inviterEphemeralPrivateKey?: Uint8Array,
|
|
25
23
|
public label?: string,
|
|
26
24
|
public maxUses?: number,
|
|
27
25
|
public usedBy: string[] = [],
|
|
@@ -31,14 +29,14 @@ export class Invite {
|
|
|
31
29
|
if (!inviter) {
|
|
32
30
|
throw new Error("Inviter public key is required");
|
|
33
31
|
}
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const
|
|
32
|
+
const inviterEphemeralPrivateKey = generateSecretKey();
|
|
33
|
+
const inviterEphemeralPublicKey = getPublicKey(inviterEphemeralPrivateKey);
|
|
34
|
+
const sharedSecret = bytesToHex(generateSecretKey());
|
|
37
35
|
return new Invite(
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
inviterEphemeralPublicKey,
|
|
37
|
+
sharedSecret,
|
|
40
38
|
inviter,
|
|
41
|
-
|
|
39
|
+
inviterEphemeralPrivateKey,
|
|
42
40
|
label,
|
|
43
41
|
maxUses
|
|
44
42
|
);
|
|
@@ -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,
|
|
63
|
-
if (!inviter || !
|
|
64
|
-
throw new Error("Missing required fields (inviter,
|
|
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
|
-
|
|
69
|
-
|
|
66
|
+
ephemeralKey,
|
|
67
|
+
sharedSecret,
|
|
70
68
|
inviter
|
|
71
69
|
);
|
|
72
70
|
}
|
|
@@ -74,10 +72,10 @@ export class Invite {
|
|
|
74
72
|
static deserialize(json: string): Invite {
|
|
75
73
|
const data = JSON.parse(json);
|
|
76
74
|
return new Invite(
|
|
77
|
-
data.
|
|
78
|
-
data.
|
|
75
|
+
data.inviterEphemeralPublicKey,
|
|
76
|
+
data.sharedSecret,
|
|
79
77
|
data.inviter,
|
|
80
|
-
data.
|
|
78
|
+
data.inviterEphemeralPrivateKey ? new Uint8Array(data.inviterEphemeralPrivateKey) : undefined,
|
|
81
79
|
data.label,
|
|
82
80
|
data.maxUses,
|
|
83
81
|
data.usedBy
|
|
@@ -92,17 +90,17 @@ export class Invite {
|
|
|
92
90
|
throw new Error("Event signature is invalid");
|
|
93
91
|
}
|
|
94
92
|
const { tags } = event;
|
|
95
|
-
const
|
|
96
|
-
const
|
|
93
|
+
const inviterEphemeralPublicKey = tags.find(([key]) => key === 'ephemeralKey')?.[1];
|
|
94
|
+
const sharedSecret = tags.find(([key]) => key === 'sharedSecret')?.[1];
|
|
97
95
|
const inviter = event.pubkey;
|
|
98
96
|
|
|
99
|
-
if (!
|
|
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
|
-
|
|
105
|
-
|
|
102
|
+
inviterEphemeralPublicKey,
|
|
103
|
+
sharedSecret,
|
|
106
104
|
inviter
|
|
107
105
|
);
|
|
108
106
|
}
|
|
@@ -136,10 +134,10 @@ export class Invite {
|
|
|
136
134
|
*/
|
|
137
135
|
serialize(): string {
|
|
138
136
|
return JSON.stringify({
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
inviterEphemeralPublicKey: this.inviterEphemeralPublicKey,
|
|
138
|
+
sharedSecret: this.sharedSecret,
|
|
141
139
|
inviter: this.inviter,
|
|
142
|
-
|
|
140
|
+
inviterEphemeralPrivateKey: this.inviterEphemeralPrivateKey ? Array.from(this.inviterEphemeralPrivateKey) : undefined,
|
|
143
141
|
label: this.label,
|
|
144
142
|
maxUses: this.maxUses,
|
|
145
143
|
usedBy: this.usedBy,
|
|
@@ -152,12 +150,11 @@ export class Invite {
|
|
|
152
150
|
getUrl(root = "https://iris.to") {
|
|
153
151
|
const data = {
|
|
154
152
|
inviter: this.inviter,
|
|
155
|
-
|
|
156
|
-
|
|
153
|
+
ephemeralKey: this.inviterEphemeralPublicKey,
|
|
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: [['
|
|
167
|
+
tags: [['ephemeralKey', this.inviterEphemeralPublicKey], ['sharedSecret', this.sharedSecret], ['d', 'nostr-double-ratchet/invite']],
|
|
171
168
|
};
|
|
172
169
|
}
|
|
173
170
|
|
|
@@ -189,49 +186,53 @@ 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
|
-
const inviterPublicKey = this.inviter || this.
|
|
193
|
+
const inviterPublicKey = this.inviter || this.inviterEphemeralPublicKey;
|
|
197
194
|
|
|
198
|
-
const sharedSecret = hexToBytes(this.
|
|
199
|
-
const session = Session.init(nostrSubscribe, this.
|
|
195
|
+
const sharedSecret = hexToBytes(this.sharedSecret);
|
|
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
|
|
|
216
217
|
const envelope = {
|
|
217
218
|
kind: MESSAGE_EVENT_KIND,
|
|
218
219
|
pubkey: randomSenderPublicKey,
|
|
219
|
-
content: nip44.encrypt(JSON.stringify(innerEvent), getConversationKey(randomSenderKey, this.
|
|
220
|
+
content: nip44.encrypt(JSON.stringify(innerEvent), getConversationKey(randomSenderKey, this.inviterEphemeralPublicKey)),
|
|
220
221
|
created_at: Math.floor(Date.now() / 1000),
|
|
221
|
-
tags: [['p', this.
|
|
222
|
+
tags: [['p', this.inviterEphemeralPublicKey]],
|
|
222
223
|
};
|
|
223
224
|
|
|
224
225
|
return { session, event: finalizeEvent(envelope, randomSenderKey) };
|
|
225
226
|
}
|
|
226
227
|
|
|
227
|
-
listen(
|
|
228
|
-
if (!this.
|
|
228
|
+
listen(decryptor: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (session: Session, identity?: string) => void): Unsubscribe {
|
|
229
|
+
if (!this.inviterEphemeralPrivateKey) {
|
|
229
230
|
throw new Error("Inviter session key is not available");
|
|
230
231
|
}
|
|
231
232
|
|
|
232
233
|
const filter = {
|
|
233
234
|
kinds: [MESSAGE_EVENT_KIND],
|
|
234
|
-
'#p': [this.
|
|
235
|
+
'#p': [this.inviterEphemeralPublicKey],
|
|
235
236
|
};
|
|
236
237
|
|
|
237
238
|
return nostrSubscribe(filter, async (event) => {
|
|
@@ -241,26 +242,31 @@ export class Invite {
|
|
|
241
242
|
return;
|
|
242
243
|
}
|
|
243
244
|
|
|
244
|
-
|
|
245
|
+
// Decrypt the outer envelope first
|
|
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
|
-
const session = Session.init(nostrSubscribe, inviteeSessionPublicKey, this.
|
|
269
|
+
const session = Session.init(nostrSubscribe, inviteeSessionPublicKey, this.inviterEphemeralPrivateKey!, false, sharedSecret, name);
|
|
264
270
|
|
|
265
271
|
onSession(session, inviteeIdentity);
|
|
266
272
|
} catch (error) {
|
package/src/Session.ts
CHANGED
|
@@ -33,16 +33,16 @@ export class Session {
|
|
|
33
33
|
/**
|
|
34
34
|
* Initializes a new secure communication session
|
|
35
35
|
* @param nostrSubscribe Function to subscribe to Nostr events
|
|
36
|
-
* @param
|
|
36
|
+
* @param theirNextNostrPublicKey The public key of the other party
|
|
37
37
|
* @param ourCurrentPrivateKey Our current private key for Nostr
|
|
38
38
|
* @param isInitiator Whether we are initiating the conversation (true) or responding (false)
|
|
39
39
|
* @param sharedSecret Initial shared secret for securing the first message chain
|
|
40
40
|
* @param name Optional name for the session (for debugging)
|
|
41
41
|
* @returns A new Session instance
|
|
42
42
|
*/
|
|
43
|
-
static init(nostrSubscribe: NostrSubscribe,
|
|
43
|
+
static init(nostrSubscribe: NostrSubscribe, theirNextNostrPublicKey: string, ourCurrentPrivateKey: Uint8Array, isInitiator: boolean, sharedSecret: Uint8Array, name?: string): Session {
|
|
44
44
|
const ourNextPrivateKey = generateSecretKey();
|
|
45
|
-
const [rootKey, sendingChainKey] = kdf(sharedSecret, nip44.getConversationKey(ourNextPrivateKey,
|
|
45
|
+
const [rootKey, sendingChainKey] = kdf(sharedSecret, nip44.getConversationKey(ourNextPrivateKey, theirNextNostrPublicKey), 2);
|
|
46
46
|
let ourCurrentNostrKey;
|
|
47
47
|
let ourNextNostrKey;
|
|
48
48
|
if (isInitiator) {
|
|
@@ -53,7 +53,7 @@ export class Session {
|
|
|
53
53
|
}
|
|
54
54
|
const state: SessionState = {
|
|
55
55
|
rootKey: isInitiator ? rootKey : sharedSecret,
|
|
56
|
-
|
|
56
|
+
theirNextNostrPublicKey,
|
|
57
57
|
ourCurrentNostrKey,
|
|
58
58
|
ourNextNostrKey,
|
|
59
59
|
receivingChainKey: undefined,
|
|
@@ -76,13 +76,13 @@ export class Session {
|
|
|
76
76
|
* @throws Error if we are not the initiator and trying to send the first message
|
|
77
77
|
*/
|
|
78
78
|
send(data: string): VerifiedEvent {
|
|
79
|
-
if (!this.state.
|
|
79
|
+
if (!this.state.theirNextNostrPublicKey || !this.state.ourCurrentNostrKey) {
|
|
80
80
|
throw new Error("we are not the initiator, so we can't send the first message");
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
const [header, encryptedData] = this.ratchetEncrypt(data);
|
|
84
84
|
|
|
85
|
-
const sharedSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, this.state.
|
|
85
|
+
const sharedSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, this.state.theirNextNostrPublicKey);
|
|
86
86
|
const encryptedHeader = nip44.encrypt(JSON.stringify(header), sharedSecret);
|
|
87
87
|
|
|
88
88
|
const nostrEvent = finalizeEvent({
|
|
@@ -151,13 +151,13 @@ export class Session {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
private ratchetStep(
|
|
154
|
+
private ratchetStep(theirNextNostrPublicKey: string) {
|
|
155
155
|
this.state.previousSendingChainMessageCount = this.state.sendingChainMessageNumber;
|
|
156
156
|
this.state.sendingChainMessageNumber = 0;
|
|
157
157
|
this.state.receivingChainMessageNumber = 0;
|
|
158
|
-
this.state.
|
|
158
|
+
this.state.theirNextNostrPublicKey = theirNextNostrPublicKey;
|
|
159
159
|
|
|
160
|
-
const conversationKey1 = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, this.state.
|
|
160
|
+
const conversationKey1 = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, this.state.theirNextNostrPublicKey!);
|
|
161
161
|
const [theirRootKey, receivingChainKey] = kdf(this.state.rootKey, conversationKey1, 2);
|
|
162
162
|
|
|
163
163
|
this.state.receivingChainKey = receivingChainKey;
|
|
@@ -169,7 +169,7 @@ export class Session {
|
|
|
169
169
|
privateKey: ourNextSecretKey
|
|
170
170
|
};
|
|
171
171
|
|
|
172
|
-
const conversationKey2 = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, this.state.
|
|
172
|
+
const conversationKey2 = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, this.state.theirNextNostrPublicKey!);
|
|
173
173
|
const [rootKey, sendingChainKey] = kdf(theirRootKey, conversationKey2, 2);
|
|
174
174
|
this.state.rootKey = rootKey;
|
|
175
175
|
this.state.sendingChainKey = sendingChainKey;
|
|
@@ -261,12 +261,13 @@ export class Session {
|
|
|
261
261
|
const [header, shouldRatchet, isSkipped] = this.decryptHeader(e);
|
|
262
262
|
|
|
263
263
|
if (!isSkipped) {
|
|
264
|
-
if (this.state.
|
|
265
|
-
this.state.
|
|
264
|
+
if (this.state.theirNextNostrPublicKey !== header.nextPublicKey) {
|
|
265
|
+
this.state.theirCurrentNostrPublicKey = this.state.theirNextNostrPublicKey;
|
|
266
|
+
this.state.theirNextNostrPublicKey = header.nextPublicKey;
|
|
266
267
|
this.nostrUnsubscribe?.(); // should we keep this open for a while? maybe as long as we have skipped messages?
|
|
267
268
|
this.nostrUnsubscribe = this.nostrNextUnsubscribe;
|
|
268
269
|
this.nostrNextUnsubscribe = this.nostrSubscribe(
|
|
269
|
-
{authors: [this.state.
|
|
270
|
+
{authors: [this.state.theirNextNostrPublicKey], kinds: [MESSAGE_EVENT_KIND]},
|
|
270
271
|
(e) => this.handleNostrEvent(e)
|
|
271
272
|
);
|
|
272
273
|
}
|
|
@@ -290,18 +291,19 @@ export class Session {
|
|
|
290
291
|
private subscribeToNostrEvents() {
|
|
291
292
|
if (this.nostrNextUnsubscribe) return;
|
|
292
293
|
this.nostrNextUnsubscribe = this.nostrSubscribe(
|
|
293
|
-
{authors: [this.state.
|
|
294
|
+
{authors: [this.state.theirNextNostrPublicKey], kinds: [MESSAGE_EVENT_KIND]},
|
|
294
295
|
(e) => this.handleNostrEvent(e)
|
|
295
296
|
);
|
|
296
297
|
|
|
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
|
-
);
|
|
298
|
+
const authors = Object.keys(this.state.skippedHeaderKeys);
|
|
299
|
+
if (this.state.theirCurrentNostrPublicKey && !authors.includes(this.state.theirCurrentNostrPublicKey)) {
|
|
300
|
+
authors.push(this.state.theirCurrentNostrPublicKey)
|
|
305
301
|
}
|
|
302
|
+
// do we want this unsubscribed on rotation or should we keep it open
|
|
303
|
+
// in case more skipped messages are found by relays or peers?
|
|
304
|
+
this.nostrUnsubscribe = this.nostrSubscribe(
|
|
305
|
+
{authors, kinds: [MESSAGE_EVENT_KIND]},
|
|
306
|
+
(e) => this.handleNostrEvent(e)
|
|
307
|
+
);
|
|
306
308
|
}
|
|
307
309
|
}
|
|
@@ -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
|
@@ -33,7 +33,10 @@ export interface SessionState {
|
|
|
33
33
|
rootKey: Uint8Array;
|
|
34
34
|
|
|
35
35
|
/** The other party's current Nostr public key */
|
|
36
|
-
|
|
36
|
+
theirCurrentNostrPublicKey?: string;
|
|
37
|
+
|
|
38
|
+
/** The other party's next Nostr public key */
|
|
39
|
+
theirNextNostrPublicKey: string;
|
|
37
40
|
|
|
38
41
|
/** Our current Nostr keypair used for this session */
|
|
39
42
|
ourCurrentNostrKey?: KeyPair;
|
package/src/utils.ts
CHANGED
|
@@ -7,7 +7,8 @@ import { sha256 } from '@noble/hashes/sha256';
|
|
|
7
7
|
export function serializeSessionState(state: SessionState): string {
|
|
8
8
|
return JSON.stringify({
|
|
9
9
|
rootKey: bytesToHex(state.rootKey),
|
|
10
|
-
|
|
10
|
+
theirCurrentNostrPublicKey: state.theirCurrentNostrPublicKey,
|
|
11
|
+
theirNextNostrPublicKey: state.theirNextNostrPublicKey,
|
|
11
12
|
ourCurrentNostrKey: state.ourCurrentNostrKey ? {
|
|
12
13
|
publicKey: state.ourCurrentNostrKey.publicKey,
|
|
13
14
|
privateKey: bytesToHex(state.ourCurrentNostrKey.privateKey),
|
|
@@ -40,7 +41,8 @@ export function deserializeSessionState(data: string): SessionState {
|
|
|
40
41
|
const state = JSON.parse(data);
|
|
41
42
|
return {
|
|
42
43
|
rootKey: hexToBytes(state.rootKey),
|
|
43
|
-
|
|
44
|
+
theirCurrentNostrPublicKey: state.theirCurrentNostrPublicKey,
|
|
45
|
+
theirNextNostrPublicKey: state.theirNextNostrPublicKey,
|
|
44
46
|
ourCurrentNostrKey: state.ourCurrentNostrKey ? {
|
|
45
47
|
publicKey: state.ourCurrentNostrKey.publicKey,
|
|
46
48
|
privateKey: hexToBytes(state.ourCurrentNostrKey.privateKey),
|