nostr-double-ratchet 0.0.20 → 0.0.22
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.map +1 -1
- package/dist/Session.d.ts.map +1 -1
- package/dist/nostr-double-ratchet.es.js +767 -744
- package/dist/nostr-double-ratchet.umd.js +1 -1
- package/dist/types.d.ts +10 -4
- 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 +13 -14
- package/src/Session.ts +40 -46
- package/src/types.ts +9 -5
- package/src/utils.ts +64 -20
package/src/Invite.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { generateSecretKey, getPublicKey, nip44, finalizeEvent, VerifiedEvent, UnsignedEvent, verifyEvent, Filter } from "nostr-tools";
|
|
2
|
-
import { INVITE_EVENT_KIND, NostrSubscribe, Unsubscribe,
|
|
2
|
+
import { INVITE_EVENT_KIND, NostrSubscribe, Unsubscribe, EncryptFunction, DecryptFunction, INVITE_RESPONSE_KIND } from "./types";
|
|
3
3
|
import { getConversationKey } from "nostr-tools/nip44";
|
|
4
4
|
import { Session } from "./Session.ts";
|
|
5
5
|
import { hexToBytes, bytesToHex } from "@noble/hashes/utils";
|
|
6
6
|
|
|
7
|
+
const TWO_DAYS = 2 * 24 * 60 * 60
|
|
8
|
+
|
|
9
|
+
const now = () => Math.round(Date.now() / 1000)
|
|
10
|
+
const randomNow = () => Math.round(now() - Math.random() * TWO_DAYS)
|
|
11
|
+
|
|
7
12
|
/**
|
|
8
13
|
* Invite is a safe way to exchange session keys and initiate secret sessions.
|
|
9
14
|
*
|
|
@@ -205,10 +210,6 @@ export class Invite {
|
|
|
205
210
|
const sharedSecret = hexToBytes(this.sharedSecret);
|
|
206
211
|
const session = Session.init(nostrSubscribe, this.inviterEphemeralPublicKey, inviteeSessionKey, true, sharedSecret, undefined);
|
|
207
212
|
|
|
208
|
-
// Create a random keypair for the envelope sender
|
|
209
|
-
const randomSenderKey = generateSecretKey();
|
|
210
|
-
const randomSenderPublicKey = getPublicKey(randomSenderKey);
|
|
211
|
-
|
|
212
213
|
// should we take only Encrypt / Decrypt functions, not keys, to make it simpler and with less imports here?
|
|
213
214
|
// common implementation problem: plaintext, pubkey params in different order
|
|
214
215
|
const encrypt = typeof encryptor === 'function' ?
|
|
@@ -219,16 +220,19 @@ export class Invite {
|
|
|
219
220
|
|
|
220
221
|
const innerEvent = {
|
|
221
222
|
pubkey: inviteePublicKey,
|
|
222
|
-
tags: [['sharedSecret', this.sharedSecret]],
|
|
223
223
|
content: await nip44.encrypt(dhEncrypted, sharedSecret),
|
|
224
224
|
created_at: Math.floor(Date.now() / 1000),
|
|
225
225
|
};
|
|
226
226
|
|
|
227
|
+
// Create a random keypair for the envelope sender
|
|
228
|
+
const randomSenderKey = generateSecretKey();
|
|
229
|
+
const randomSenderPublicKey = getPublicKey(randomSenderKey);
|
|
230
|
+
|
|
227
231
|
const envelope = {
|
|
228
|
-
kind:
|
|
232
|
+
kind: INVITE_RESPONSE_KIND,
|
|
229
233
|
pubkey: randomSenderPublicKey,
|
|
230
234
|
content: nip44.encrypt(JSON.stringify(innerEvent), getConversationKey(randomSenderKey, this.inviterEphemeralPublicKey)),
|
|
231
|
-
created_at:
|
|
235
|
+
created_at: randomNow(),
|
|
232
236
|
tags: [['p', this.inviterEphemeralPublicKey]],
|
|
233
237
|
};
|
|
234
238
|
|
|
@@ -241,7 +245,7 @@ export class Invite {
|
|
|
241
245
|
}
|
|
242
246
|
|
|
243
247
|
const filter = {
|
|
244
|
-
kinds: [
|
|
248
|
+
kinds: [INVITE_RESPONSE_KIND],
|
|
245
249
|
'#p': [this.inviterEphemeralPublicKey],
|
|
246
250
|
};
|
|
247
251
|
|
|
@@ -256,11 +260,6 @@ export class Invite {
|
|
|
256
260
|
const decrypted = await nip44.decrypt(event.content, getConversationKey(this.inviterEphemeralPrivateKey!, event.pubkey));
|
|
257
261
|
const innerEvent = JSON.parse(decrypted);
|
|
258
262
|
|
|
259
|
-
if (!innerEvent.tags || !innerEvent.tags.some(([key, value]: [string, string]) => key === 'sharedSecret' && value === this.sharedSecret)) {
|
|
260
|
-
console.error("Invalid secret from event", event);
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
263
|
const sharedSecret = hexToBytes(this.sharedSecret);
|
|
265
264
|
const inviteeIdentity = innerEvent.pubkey;
|
|
266
265
|
this.usedBy.push(inviteeIdentity);
|
package/src/Session.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
Rumor,
|
|
11
11
|
CHAT_MESSAGE_KIND,
|
|
12
12
|
} from "./types";
|
|
13
|
-
import { kdf
|
|
13
|
+
import { kdf } from "./utils";
|
|
14
14
|
|
|
15
15
|
const MAX_SKIP = 1000;
|
|
16
16
|
|
|
@@ -66,8 +66,7 @@ export class Session {
|
|
|
66
66
|
sendingChainMessageNumber: 0,
|
|
67
67
|
receivingChainMessageNumber: 0,
|
|
68
68
|
previousSendingChainMessageCount: 0,
|
|
69
|
-
|
|
70
|
-
skippedHeaderKeys: {},
|
|
69
|
+
skippedKeys: {},
|
|
71
70
|
};
|
|
72
71
|
const session = new Session(nostrSubscribe, state);
|
|
73
72
|
if (name) session.name = name;
|
|
@@ -130,7 +129,7 @@ export class Session {
|
|
|
130
129
|
content: encryptedData,
|
|
131
130
|
kind: MESSAGE_EVENT_KIND,
|
|
132
131
|
tags: [["header", encryptedHeader]],
|
|
133
|
-
created_at: Math.floor(
|
|
132
|
+
created_at: Math.floor(now / 1000)
|
|
134
133
|
}, this.state.ourCurrentNostrKey.privateKey);
|
|
135
134
|
|
|
136
135
|
return {event: nostrEvent, innerEvent: rumor as Rumor};
|
|
@@ -220,45 +219,47 @@ export class Session {
|
|
|
220
219
|
if (this.state.receivingChainMessageNumber + MAX_SKIP < until) {
|
|
221
220
|
throw new Error("Too many skipped messages");
|
|
222
221
|
}
|
|
222
|
+
|
|
223
|
+
if (!this.state.skippedKeys[nostrSender]) {
|
|
224
|
+
this.state.skippedKeys[nostrSender] = {
|
|
225
|
+
headerKeys: [],
|
|
226
|
+
messageKeys: {}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// Store header keys
|
|
230
|
+
if (this.state.ourCurrentNostrKey) {
|
|
231
|
+
const currentSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, nostrSender);
|
|
232
|
+
this.state.skippedKeys[nostrSender].headerKeys.push(currentSecret);
|
|
233
|
+
}
|
|
234
|
+
const nextSecret = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, nostrSender);
|
|
235
|
+
this.state.skippedKeys[nostrSender].headerKeys.push(nextSecret);
|
|
236
|
+
}
|
|
237
|
+
|
|
223
238
|
while (this.state.receivingChainMessageNumber < until) {
|
|
224
239
|
const [newReceivingChainKey, messageKey] = kdf(this.state.receivingChainKey!, new Uint8Array([1]), 2);
|
|
225
240
|
this.state.receivingChainKey = newReceivingChainKey;
|
|
226
|
-
|
|
227
|
-
this.state.skippedMessageKeys[key] = messageKey;
|
|
228
|
-
|
|
229
|
-
if (!this.state.skippedHeaderKeys[nostrSender]) {
|
|
230
|
-
const secrets: Uint8Array[] = [];
|
|
231
|
-
if (this.state.ourCurrentNostrKey) {
|
|
232
|
-
const currentSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, nostrSender);
|
|
233
|
-
secrets.push(currentSecret);
|
|
234
|
-
}
|
|
235
|
-
const nextSecret = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, nostrSender);
|
|
236
|
-
secrets.push(nextSecret);
|
|
237
|
-
this.state.skippedHeaderKeys[nostrSender] = secrets;
|
|
238
|
-
}
|
|
239
|
-
|
|
241
|
+
this.state.skippedKeys[nostrSender].messageKeys[this.state.receivingChainMessageNumber] = messageKey;
|
|
240
242
|
this.state.receivingChainMessageNumber++;
|
|
241
243
|
}
|
|
242
244
|
}
|
|
243
245
|
|
|
244
246
|
private trySkippedMessageKeys(header: Header, ciphertext: string, nostrSender: string): string | null {
|
|
245
|
-
const
|
|
246
|
-
if (
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
return nip44.decrypt(ciphertext, mk);
|
|
247
|
+
const skippedKeys = this.state.skippedKeys[nostrSender];
|
|
248
|
+
if (!skippedKeys) return null;
|
|
249
|
+
|
|
250
|
+
const messageKey = skippedKeys.messageKeys[header.number];
|
|
251
|
+
if (!messageKey) return null;
|
|
252
|
+
|
|
253
|
+
delete skippedKeys.messageKeys[header.number];
|
|
254
|
+
|
|
255
|
+
// Clean up if no more skipped messages
|
|
256
|
+
if (Object.keys(skippedKeys.messageKeys).length === 0) {
|
|
257
|
+
delete this.state.skippedKeys[nostrSender];
|
|
258
|
+
this.nostrUnsubscribe?.();
|
|
259
|
+
this.nostrUnsubscribe = undefined;
|
|
260
260
|
}
|
|
261
|
-
|
|
261
|
+
|
|
262
|
+
return nip44.decrypt(ciphertext, messageKey);
|
|
262
263
|
}
|
|
263
264
|
|
|
264
265
|
// 4. NOSTR EVENT HANDLING
|
|
@@ -282,9 +283,9 @@ export class Session {
|
|
|
282
283
|
// Decryption with nextSecret also failed
|
|
283
284
|
}
|
|
284
285
|
|
|
285
|
-
const
|
|
286
|
-
if (
|
|
287
|
-
for (const key of
|
|
286
|
+
const skippedKeys = this.state.skippedKeys[event.pubkey];
|
|
287
|
+
if (skippedKeys?.headerKeys) {
|
|
288
|
+
for (const key of skippedKeys.headerKeys) {
|
|
288
289
|
try {
|
|
289
290
|
const header = JSON.parse(nip44.decrypt(encryptedHeader, key)) as Header;
|
|
290
291
|
return [header, false, true];
|
|
@@ -304,7 +305,7 @@ export class Session {
|
|
|
304
305
|
if (this.state.theirNextNostrPublicKey !== header.nextPublicKey) {
|
|
305
306
|
this.state.theirCurrentNostrPublicKey = this.state.theirNextNostrPublicKey;
|
|
306
307
|
this.state.theirNextNostrPublicKey = header.nextPublicKey;
|
|
307
|
-
this.nostrUnsubscribe?.();
|
|
308
|
+
this.nostrUnsubscribe?.();
|
|
308
309
|
this.nostrUnsubscribe = this.nostrNextUnsubscribe;
|
|
309
310
|
this.nostrNextUnsubscribe = this.nostrSubscribe(
|
|
310
311
|
{authors: [this.state.theirNextNostrPublicKey], kinds: [MESSAGE_EVENT_KIND]},
|
|
@@ -316,11 +317,6 @@ export class Session {
|
|
|
316
317
|
this.skipMessageKeys(header.previousChainLength, e.pubkey);
|
|
317
318
|
this.ratchetStep(header.nextPublicKey);
|
|
318
319
|
}
|
|
319
|
-
} else {
|
|
320
|
-
const key = skippedMessageIndexKey(e.pubkey, header.number);
|
|
321
|
-
if (!(key in this.state.skippedMessageKeys)) {
|
|
322
|
-
return // maybe we already processed this message
|
|
323
|
-
}
|
|
324
320
|
}
|
|
325
321
|
|
|
326
322
|
const text = this.ratchetDecrypt(header, e.content, e.pubkey);
|
|
@@ -345,12 +341,10 @@ export class Session {
|
|
|
345
341
|
(e) => this.handleNostrEvent(e)
|
|
346
342
|
);
|
|
347
343
|
|
|
348
|
-
const authors = Object.keys(this.state.
|
|
344
|
+
const authors = Object.keys(this.state.skippedKeys);
|
|
349
345
|
if (this.state.theirCurrentNostrPublicKey && !authors.includes(this.state.theirCurrentNostrPublicKey)) {
|
|
350
346
|
authors.push(this.state.theirCurrentNostrPublicKey)
|
|
351
347
|
}
|
|
352
|
-
// do we want this unsubscribed on rotation or should we keep it open
|
|
353
|
-
// in case more skipped messages are found by relays or peers?
|
|
354
348
|
this.nostrUnsubscribe = this.nostrSubscribe(
|
|
355
349
|
{authors, kinds: [MESSAGE_EVENT_KIND]},
|
|
356
350
|
(e) => this.handleNostrEvent(e)
|
package/src/types.ts
CHANGED
|
@@ -48,11 +48,13 @@ export interface SessionState {
|
|
|
48
48
|
/** Number of messages sent in previous sending chain */
|
|
49
49
|
previousSendingChainMessageCount: number;
|
|
50
50
|
|
|
51
|
-
/** Cache of message keys for handling out-of-order messages */
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
/** Cache of message & header keys for handling out-of-order messages */
|
|
52
|
+
skippedKeys: {
|
|
53
|
+
[pubKey: string]: {
|
|
54
|
+
headerKeys: Uint8Array[],
|
|
55
|
+
messageKeys: {[msgIndex: number]: Uint8Array}
|
|
56
|
+
};
|
|
57
|
+
};
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/**
|
|
@@ -83,6 +85,8 @@ export const MESSAGE_EVENT_KIND = 30078;
|
|
|
83
85
|
*/
|
|
84
86
|
export const INVITE_EVENT_KIND = 30078;
|
|
85
87
|
|
|
88
|
+
export const INVITE_RESPONSE_KIND = 1059;
|
|
89
|
+
|
|
86
90
|
export const CHAT_MESSAGE_KIND = 14;
|
|
87
91
|
|
|
88
92
|
export const MAX_SKIP = 100;
|
package/src/utils.ts
CHANGED
|
@@ -4,8 +4,11 @@ import { Session } from "./Session.ts";
|
|
|
4
4
|
import { extract as hkdf_extract, expand as hkdf_expand } from '@noble/hashes/hkdf';
|
|
5
5
|
import { sha256 } from '@noble/hashes/sha256';
|
|
6
6
|
|
|
7
|
+
const VERSION_NUMBER = 1;
|
|
8
|
+
|
|
7
9
|
export function serializeSessionState(state: SessionState): string {
|
|
8
10
|
return JSON.stringify({
|
|
11
|
+
version: VERSION_NUMBER,
|
|
9
12
|
rootKey: bytesToHex(state.rootKey),
|
|
10
13
|
theirCurrentNostrPublicKey: state.theirCurrentNostrPublicKey,
|
|
11
14
|
theirNextNostrPublicKey: state.theirNextNostrPublicKey,
|
|
@@ -22,16 +25,18 @@ export function serializeSessionState(state: SessionState): string {
|
|
|
22
25
|
sendingChainMessageNumber: state.sendingChainMessageNumber,
|
|
23
26
|
receivingChainMessageNumber: state.receivingChainMessageNumber,
|
|
24
27
|
previousSendingChainMessageCount: state.previousSendingChainMessageCount,
|
|
25
|
-
|
|
26
|
-
Object.entries(state.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
skippedKeys: Object.fromEntries(
|
|
29
|
+
Object.entries(state.skippedKeys).map(([pubKey, value]) => [
|
|
30
|
+
pubKey,
|
|
31
|
+
{
|
|
32
|
+
headerKeys: value.headerKeys.map(bytes => bytesToHex(bytes)),
|
|
33
|
+
messageKeys: Object.fromEntries(
|
|
34
|
+
Object.entries(value.messageKeys).map(([msgIndex, bytes]) => [
|
|
35
|
+
msgIndex,
|
|
36
|
+
bytesToHex(bytes)
|
|
37
|
+
])
|
|
38
|
+
)
|
|
39
|
+
}
|
|
35
40
|
])
|
|
36
41
|
),
|
|
37
42
|
});
|
|
@@ -39,6 +44,43 @@ export function serializeSessionState(state: SessionState): string {
|
|
|
39
44
|
|
|
40
45
|
export function deserializeSessionState(data: string): SessionState {
|
|
41
46
|
const state = JSON.parse(data);
|
|
47
|
+
|
|
48
|
+
// Handle version 0 (legacy format)
|
|
49
|
+
if (!state.version) {
|
|
50
|
+
const skippedKeys: SessionState['skippedKeys'] = {};
|
|
51
|
+
|
|
52
|
+
// Migrate old skipped keys format to new structure
|
|
53
|
+
if (state.skippedMessageKeys) {
|
|
54
|
+
Object.entries(state.skippedMessageKeys).forEach(([pubKey, messageKeys]: [string, any]) => {
|
|
55
|
+
skippedKeys[pubKey] = {
|
|
56
|
+
headerKeys: state.skippedHeaderKeys?.[pubKey] || [],
|
|
57
|
+
messageKeys: messageKeys
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
rootKey: hexToBytes(state.rootKey),
|
|
64
|
+
theirCurrentNostrPublicKey: state.theirCurrentNostrPublicKey,
|
|
65
|
+
theirNextNostrPublicKey: state.theirNextNostrPublicKey,
|
|
66
|
+
ourCurrentNostrKey: state.ourCurrentNostrKey ? {
|
|
67
|
+
publicKey: state.ourCurrentNostrKey.publicKey,
|
|
68
|
+
privateKey: hexToBytes(state.ourCurrentNostrKey.privateKey),
|
|
69
|
+
} : undefined,
|
|
70
|
+
ourNextNostrKey: {
|
|
71
|
+
publicKey: state.ourNextNostrKey.publicKey,
|
|
72
|
+
privateKey: hexToBytes(state.ourNextNostrKey.privateKey),
|
|
73
|
+
},
|
|
74
|
+
receivingChainKey: state.receivingChainKey ? hexToBytes(state.receivingChainKey) : undefined,
|
|
75
|
+
sendingChainKey: state.sendingChainKey ? hexToBytes(state.sendingChainKey) : undefined,
|
|
76
|
+
sendingChainMessageNumber: state.sendingChainMessageNumber,
|
|
77
|
+
receivingChainMessageNumber: state.receivingChainMessageNumber,
|
|
78
|
+
previousSendingChainMessageCount: state.previousSendingChainMessageCount,
|
|
79
|
+
skippedKeys
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Handle current version
|
|
42
84
|
return {
|
|
43
85
|
rootKey: hexToBytes(state.rootKey),
|
|
44
86
|
theirCurrentNostrPublicKey: state.theirCurrentNostrPublicKey,
|
|
@@ -56,16 +98,18 @@ export function deserializeSessionState(data: string): SessionState {
|
|
|
56
98
|
sendingChainMessageNumber: state.sendingChainMessageNumber,
|
|
57
99
|
receivingChainMessageNumber: state.receivingChainMessageNumber,
|
|
58
100
|
previousSendingChainMessageCount: state.previousSendingChainMessageCount,
|
|
59
|
-
|
|
60
|
-
Object.entries(state.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
101
|
+
skippedKeys: Object.fromEntries(
|
|
102
|
+
Object.entries(state.skippedKeys || {}).map(([pubKey, value]) => [
|
|
103
|
+
pubKey,
|
|
104
|
+
{
|
|
105
|
+
headerKeys: (value as any).headerKeys.map((hex: string) => hexToBytes(hex)),
|
|
106
|
+
messageKeys: Object.fromEntries(
|
|
107
|
+
Object.entries((value as any).messageKeys).map(([msgIndex, hex]) => [
|
|
108
|
+
msgIndex,
|
|
109
|
+
hexToBytes(hex as string)
|
|
110
|
+
])
|
|
111
|
+
)
|
|
112
|
+
}
|
|
69
113
|
])
|
|
70
114
|
),
|
|
71
115
|
};
|