nostr-double-ratchet 0.0.5 → 0.0.7
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/Channel.d.ts +4 -0
- package/dist/Channel.d.ts.map +1 -1
- package/dist/InviteLink.d.ts.map +1 -1
- package/dist/nostr-double-ratchet.es.js +726 -671
- package/dist/nostr-double-ratchet.umd.js +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/Channel.ts +68 -18
- package/src/InviteLink.ts +30 -17
- package/src/types.ts +3 -0
- package/src/utils.ts +12 -0
package/src/Channel.ts
CHANGED
|
@@ -62,6 +62,7 @@ export class Channel {
|
|
|
62
62
|
receivingChainMessageNumber: 0,
|
|
63
63
|
previousSendingChainMessageCount: 0,
|
|
64
64
|
skippedMessageKeys: {},
|
|
65
|
+
skippedHeaderKeys: {},
|
|
65
66
|
};
|
|
66
67
|
const channel = new Channel(nostrSubscribe, state);
|
|
67
68
|
if (name) channel.name = name;
|
|
@@ -106,6 +107,14 @@ export class Channel {
|
|
|
106
107
|
return () => this.internalSubscriptions.delete(id)
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Stop listening to incoming messages
|
|
112
|
+
*/
|
|
113
|
+
close() {
|
|
114
|
+
this.nostrUnsubscribe?.();
|
|
115
|
+
this.nostrNextUnsubscribe?.();
|
|
116
|
+
}
|
|
117
|
+
|
|
109
118
|
// 2. RATCHET FUNCTIONS
|
|
110
119
|
private ratchetEncrypt(plaintext: string): [Header, string] {
|
|
111
120
|
const [newSendingChainKey, messageKey] = kdf(this.state.sendingChainKey!, new Uint8Array([1]), 2);
|
|
@@ -176,6 +185,18 @@ export class Channel {
|
|
|
176
185
|
this.state.receivingChainKey = newReceivingChainKey;
|
|
177
186
|
const key = skippedMessageIndexKey(nostrSender, this.state.receivingChainMessageNumber);
|
|
178
187
|
this.state.skippedMessageKeys[key] = messageKey;
|
|
188
|
+
|
|
189
|
+
if (!this.state.skippedHeaderKeys[nostrSender]) {
|
|
190
|
+
const secrets: Uint8Array[] = [];
|
|
191
|
+
if (this.state.ourCurrentNostrKey) {
|
|
192
|
+
const currentSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, nostrSender);
|
|
193
|
+
secrets.push(currentSecret);
|
|
194
|
+
}
|
|
195
|
+
const nextSecret = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, nostrSender);
|
|
196
|
+
secrets.push(nextSecret);
|
|
197
|
+
this.state.skippedHeaderKeys[nostrSender] = secrets;
|
|
198
|
+
}
|
|
199
|
+
|
|
179
200
|
this.state.receivingChainMessageNumber++;
|
|
180
201
|
}
|
|
181
202
|
}
|
|
@@ -185,19 +206,29 @@ export class Channel {
|
|
|
185
206
|
if (key in this.state.skippedMessageKeys) {
|
|
186
207
|
const mk = this.state.skippedMessageKeys[key];
|
|
187
208
|
delete this.state.skippedMessageKeys[key];
|
|
209
|
+
|
|
210
|
+
// Check if we have any remaining skipped messages from this sender
|
|
211
|
+
const hasMoreSkippedMessages = Object.keys(this.state.skippedMessageKeys).some(k => k.startsWith(`${nostrSender}:`));
|
|
212
|
+
if (!hasMoreSkippedMessages) {
|
|
213
|
+
// Clean up header keys and unsubscribe as no more skipped messages from this sender
|
|
214
|
+
delete this.state.skippedHeaderKeys[nostrSender];
|
|
215
|
+
this.nostrUnsubscribe?.();
|
|
216
|
+
this.nostrUnsubscribe = undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
188
219
|
return nip44.decrypt(ciphertext, mk);
|
|
189
220
|
}
|
|
190
221
|
return null;
|
|
191
222
|
}
|
|
192
223
|
|
|
193
224
|
// 4. NOSTR EVENT HANDLING
|
|
194
|
-
private decryptHeader(event: any): [Header, boolean] {
|
|
225
|
+
private decryptHeader(event: any): [Header, boolean, boolean] {
|
|
195
226
|
const encryptedHeader = event.tags[0][1];
|
|
196
227
|
if (this.state.ourCurrentNostrKey) {
|
|
197
228
|
const currentSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, event.pubkey);
|
|
198
229
|
try {
|
|
199
230
|
const header = JSON.parse(nip44.decrypt(encryptedHeader, currentSecret)) as Header;
|
|
200
|
-
return [header, false];
|
|
231
|
+
return [header, false, false];
|
|
201
232
|
} catch (error) {
|
|
202
233
|
// Decryption with currentSecret failed, try with nextSecret
|
|
203
234
|
}
|
|
@@ -206,30 +237,49 @@ export class Channel {
|
|
|
206
237
|
const nextSecret = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, event.pubkey);
|
|
207
238
|
try {
|
|
208
239
|
const header = JSON.parse(nip44.decrypt(encryptedHeader, nextSecret)) as Header;
|
|
209
|
-
return [header, true];
|
|
240
|
+
return [header, true, false];
|
|
210
241
|
} catch (error) {
|
|
211
242
|
// Decryption with nextSecret also failed
|
|
212
243
|
}
|
|
213
244
|
|
|
214
|
-
|
|
245
|
+
const keys = this.state.skippedHeaderKeys[event.pubkey];
|
|
246
|
+
if (keys) {
|
|
247
|
+
for (const key of keys) {
|
|
248
|
+
try {
|
|
249
|
+
const header = JSON.parse(nip44.decrypt(encryptedHeader, key)) as Header;
|
|
250
|
+
return [header, false, true];
|
|
251
|
+
} catch (error) {
|
|
252
|
+
// Decryption failed, try next secret
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
throw new Error("Failed to decrypt header with current and skipped header keys");
|
|
215
258
|
}
|
|
216
259
|
|
|
217
260
|
private handleNostrEvent(e: any) {
|
|
218
|
-
const [header, shouldRatchet] = this.decryptHeader(e);
|
|
261
|
+
const [header, shouldRatchet, isSkipped] = this.decryptHeader(e);
|
|
219
262
|
|
|
220
|
-
if (
|
|
221
|
-
this.state.theirNostrPublicKey
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
263
|
+
if (!isSkipped) {
|
|
264
|
+
if (this.state.theirNostrPublicKey !== header.nextPublicKey) {
|
|
265
|
+
this.state.theirNostrPublicKey = header.nextPublicKey;
|
|
266
|
+
this.nostrUnsubscribe?.(); // should we keep this open for a while? maybe as long as we have skipped messages?
|
|
267
|
+
this.nostrUnsubscribe = this.nostrNextUnsubscribe;
|
|
268
|
+
this.nostrNextUnsubscribe = this.nostrSubscribe(
|
|
269
|
+
{authors: [this.state.theirNostrPublicKey], kinds: [EVENT_KIND]},
|
|
270
|
+
(e) => this.handleNostrEvent(e)
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (shouldRatchet) {
|
|
275
|
+
this.skipMessageKeys(header.previousChainLength, e.pubkey);
|
|
276
|
+
this.ratchetStep(header.nextPublicKey);
|
|
277
|
+
}
|
|
278
|
+
} else {
|
|
279
|
+
const key = skippedMessageIndexKey(e.pubkey, header.number);
|
|
280
|
+
if (!(key in this.state.skippedMessageKeys)) {
|
|
281
|
+
return // maybe we already processed this message
|
|
282
|
+
}
|
|
233
283
|
}
|
|
234
284
|
|
|
235
285
|
const data = this.ratchetDecrypt(header, e.content, e.pubkey);
|
package/src/InviteLink.ts
CHANGED
|
@@ -44,19 +44,26 @@ export class InviteLink {
|
|
|
44
44
|
|
|
45
45
|
static fromUrl(url: string): InviteLink {
|
|
46
46
|
const parsedUrl = new URL(url);
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (!inviter) {
|
|
52
|
-
throw new Error("Inviter not found in the URL");
|
|
47
|
+
const rawHash = parsedUrl.hash.slice(1);
|
|
48
|
+
if (!rawHash) {
|
|
49
|
+
throw new Error("No invite data found in the URL hash.");
|
|
53
50
|
}
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
|
|
52
|
+
const decodedHash = decodeURIComponent(rawHash);
|
|
53
|
+
let data: any;
|
|
54
|
+
try {
|
|
55
|
+
data = JSON.parse(decodedHash);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
throw new Error("Invite data in URL hash is not valid JSON: " + err);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { inviter, sessionKey, linkSecret } = data;
|
|
61
|
+
if (!inviter || !sessionKey || !linkSecret) {
|
|
62
|
+
throw new Error("Missing required fields (inviter, sessionKey, linkSecret) in invite data.");
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
const decodedInviter = nip19.decode(inviter);
|
|
59
|
-
const decodedSessionKey = nip19.decode(
|
|
66
|
+
const decodedSessionKey = nip19.decode(sessionKey);
|
|
60
67
|
|
|
61
68
|
if (typeof decodedInviter.data !== 'string') {
|
|
62
69
|
throw new Error("Decoded inviter is not a string");
|
|
@@ -65,10 +72,11 @@ export class InviteLink {
|
|
|
65
72
|
throw new Error("Decoded session key is not a string");
|
|
66
73
|
}
|
|
67
74
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
return new InviteLink(
|
|
76
|
+
decodedSessionKey.data,
|
|
77
|
+
linkSecret,
|
|
78
|
+
decodedInviter.data
|
|
79
|
+
);
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
static deserialize(json: string): InviteLink {
|
|
@@ -97,10 +105,15 @@ export class InviteLink {
|
|
|
97
105
|
}
|
|
98
106
|
|
|
99
107
|
getUrl(root = "https://iris.to") {
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
108
|
+
const data = {
|
|
109
|
+
inviter: nip19.npubEncode(this.inviter),
|
|
110
|
+
sessionKey: nip19.npubEncode(this.inviterSessionPublicKey),
|
|
111
|
+
linkSecret: this.linkSecret
|
|
112
|
+
};
|
|
113
|
+
const url = new URL(root);
|
|
114
|
+
url.hash = encodeURIComponent(JSON.stringify(data));
|
|
115
|
+
console.log('url', url.toString())
|
|
116
|
+
return url.toString();
|
|
104
117
|
}
|
|
105
118
|
|
|
106
119
|
/**
|
package/src/types.ts
CHANGED
|
@@ -63,6 +63,9 @@ export interface ChannelState {
|
|
|
63
63
|
|
|
64
64
|
/** Cache of message keys for handling out-of-order messages */
|
|
65
65
|
skippedMessageKeys: Record<string, Uint8Array>;
|
|
66
|
+
|
|
67
|
+
/** Cache of header keys for handling out-of-order messages */
|
|
68
|
+
skippedHeaderKeys: Record<string, Uint8Array[]>;
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
/**
|
package/src/utils.ts
CHANGED
|
@@ -27,6 +27,12 @@ export function serializeChannelState(state: ChannelState): string {
|
|
|
27
27
|
bytesToHex(value),
|
|
28
28
|
])
|
|
29
29
|
),
|
|
30
|
+
skippedHeaderKeys: Object.fromEntries(
|
|
31
|
+
Object.entries(state.skippedHeaderKeys).map(([key, value]) => [
|
|
32
|
+
key,
|
|
33
|
+
value.map(bytes => bytesToHex(bytes))
|
|
34
|
+
])
|
|
35
|
+
),
|
|
30
36
|
});
|
|
31
37
|
}
|
|
32
38
|
|
|
@@ -54,6 +60,12 @@ export function deserializeChannelState(data: string): ChannelState {
|
|
|
54
60
|
hexToBytes(value as string),
|
|
55
61
|
])
|
|
56
62
|
),
|
|
63
|
+
skippedHeaderKeys: Object.fromEntries(
|
|
64
|
+
Object.entries(state.skippedHeaderKeys || {}).map(([key, value]) => [
|
|
65
|
+
key,
|
|
66
|
+
(value as string[]).map(hex => hexToBytes(hex))
|
|
67
|
+
])
|
|
68
|
+
),
|
|
57
69
|
};
|
|
58
70
|
}
|
|
59
71
|
|