nostr-double-ratchet 0.0.3 → 0.0.5
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 +23 -4
- package/dist/Channel.d.ts.map +1 -1
- package/dist/InviteLink.d.ts.map +1 -1
- package/dist/nostr-double-ratchet.es.js +483 -455
- package/dist/nostr-double-ratchet.umd.js +1 -1
- package/dist/types.d.ts +17 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -16
- package/src/Channel.ts +52 -18
- package/src/InviteLink.ts +6 -4
- package/src/types.ts +19 -1
package/src/Channel.ts
CHANGED
|
@@ -13,7 +13,9 @@ import { kdf, skippedMessageIndexKey } from "./utils";
|
|
|
13
13
|
const MAX_SKIP = 1000;
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
16
|
+
* Double ratchet secure communication channel over Nostr
|
|
17
|
+
*
|
|
18
|
+
* Very similar to Signal's "Double Ratchet with header encryption"
|
|
17
19
|
* https://signal.org/docs/specifications/doubleratchet/
|
|
18
20
|
*/
|
|
19
21
|
export class Channel {
|
|
@@ -23,15 +25,22 @@ export class Channel {
|
|
|
23
25
|
private currentInternalSubscriptionId = 0;
|
|
24
26
|
public name: string;
|
|
25
27
|
|
|
28
|
+
// 1. CHANNEL PUBLIC INTERFACE
|
|
26
29
|
constructor(private nostrSubscribe: NostrSubscribe, public state: ChannelState) {
|
|
27
30
|
this.name = Math.random().toString(36).substring(2, 6);
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
|
-
*
|
|
32
|
-
* @param
|
|
34
|
+
* Initializes a new secure communication channel
|
|
35
|
+
* @param nostrSubscribe Function to subscribe to Nostr events
|
|
36
|
+
* @param theirNostrPublicKey The public key of the other party
|
|
37
|
+
* @param ourCurrentPrivateKey Our current private key for Nostr
|
|
38
|
+
* @param isInitiator Whether we are initiating the conversation (true) or responding (false)
|
|
39
|
+
* @param sharedSecret Initial shared secret for securing the first message chain
|
|
40
|
+
* @param name Optional name for the channel (for debugging)
|
|
41
|
+
* @returns A new Channel instance
|
|
33
42
|
*/
|
|
34
|
-
static init(nostrSubscribe: NostrSubscribe, theirNostrPublicKey: string, ourCurrentPrivateKey: Uint8Array,
|
|
43
|
+
static init(nostrSubscribe: NostrSubscribe, theirNostrPublicKey: string, ourCurrentPrivateKey: Uint8Array, isInitiator: boolean, sharedSecret: Uint8Array, name?: string): Channel {
|
|
35
44
|
const ourNextPrivateKey = generateSecretKey();
|
|
36
45
|
const [rootKey, sendingChainKey] = kdf(sharedSecret, nip44.getConversationKey(ourNextPrivateKey, theirNostrPublicKey), 2);
|
|
37
46
|
let ourCurrentNostrKey;
|
|
@@ -56,10 +65,15 @@ export class Channel {
|
|
|
56
65
|
};
|
|
57
66
|
const channel = new Channel(nostrSubscribe, state);
|
|
58
67
|
if (name) channel.name = name;
|
|
59
|
-
console.log(channel.name, 'initial root key', bytesToHex(state.rootKey).slice(0,4))
|
|
60
68
|
return channel;
|
|
61
69
|
}
|
|
62
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Sends an encrypted message through the channel
|
|
73
|
+
* @param data The plaintext message to send
|
|
74
|
+
* @returns A verified Nostr event containing the encrypted message
|
|
75
|
+
* @throws Error if we are not the initiator and trying to send the first message
|
|
76
|
+
*/
|
|
63
77
|
send(data: string): VerifiedEvent {
|
|
64
78
|
if (!this.state.theirNostrPublicKey || !this.state.ourCurrentNostrKey) {
|
|
65
79
|
throw new Error("we are not the initiator, so we can't send the first message");
|
|
@@ -80,6 +94,11 @@ export class Channel {
|
|
|
80
94
|
return nostrEvent;
|
|
81
95
|
}
|
|
82
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Subscribes to incoming messages on this channel
|
|
99
|
+
* @param callback Function to be called when a message is received
|
|
100
|
+
* @returns Unsubscribe function to stop receiving messages
|
|
101
|
+
*/
|
|
83
102
|
onMessage(callback: MessageCallback): Unsubscribe {
|
|
84
103
|
const id = this.currentInternalSubscriptionId++
|
|
85
104
|
this.internalSubscriptions.set(id, callback)
|
|
@@ -87,6 +106,7 @@ export class Channel {
|
|
|
87
106
|
return () => this.internalSubscriptions.delete(id)
|
|
88
107
|
}
|
|
89
108
|
|
|
109
|
+
// 2. RATCHET FUNCTIONS
|
|
90
110
|
private ratchetEncrypt(plaintext: string): [Header, string] {
|
|
91
111
|
const [newSendingChainKey, messageKey] = kdf(this.state.sendingChainKey!, new Uint8Array([1]), 2);
|
|
92
112
|
this.state.sendingChainKey = newSendingChainKey;
|
|
@@ -129,7 +149,7 @@ export class Channel {
|
|
|
129
149
|
this.state.theirNostrPublicKey = theirNostrPublicKey;
|
|
130
150
|
|
|
131
151
|
const conversationKey1 = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, this.state.theirNostrPublicKey!);
|
|
132
|
-
const [
|
|
152
|
+
const [theirRootKey, receivingChainKey] = kdf(this.state.rootKey, conversationKey1, 2);
|
|
133
153
|
|
|
134
154
|
this.state.receivingChainKey = receivingChainKey;
|
|
135
155
|
|
|
@@ -141,11 +161,12 @@ export class Channel {
|
|
|
141
161
|
};
|
|
142
162
|
|
|
143
163
|
const conversationKey2 = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, this.state.theirNostrPublicKey!);
|
|
144
|
-
const [
|
|
145
|
-
this.state.rootKey =
|
|
164
|
+
const [rootKey, sendingChainKey] = kdf(theirRootKey, conversationKey2, 2);
|
|
165
|
+
this.state.rootKey = rootKey;
|
|
146
166
|
this.state.sendingChainKey = sendingChainKey;
|
|
147
167
|
}
|
|
148
168
|
|
|
169
|
+
// 3. MESSAGE KEY FUNCTIONS
|
|
149
170
|
private skipMessageKeys(until: number, nostrSender: string) {
|
|
150
171
|
if (this.state.receivingChainMessageNumber + MAX_SKIP < until) {
|
|
151
172
|
throw new Error("Too many skipped messages");
|
|
@@ -169,10 +190,11 @@ export class Channel {
|
|
|
169
190
|
return null;
|
|
170
191
|
}
|
|
171
192
|
|
|
172
|
-
|
|
173
|
-
|
|
193
|
+
// 4. NOSTR EVENT HANDLING
|
|
194
|
+
private decryptHeader(event: any): [Header, boolean] {
|
|
195
|
+
const encryptedHeader = event.tags[0][1];
|
|
174
196
|
if (this.state.ourCurrentNostrKey) {
|
|
175
|
-
const currentSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey,
|
|
197
|
+
const currentSecret = nip44.getConversationKey(this.state.ourCurrentNostrKey.privateKey, event.pubkey);
|
|
176
198
|
try {
|
|
177
199
|
const header = JSON.parse(nip44.decrypt(encryptedHeader, currentSecret)) as Header;
|
|
178
200
|
return [header, false];
|
|
@@ -181,7 +203,7 @@ export class Channel {
|
|
|
181
203
|
}
|
|
182
204
|
}
|
|
183
205
|
|
|
184
|
-
const nextSecret = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey,
|
|
206
|
+
const nextSecret = nip44.getConversationKey(this.state.ourNextNostrKey.privateKey, event.pubkey);
|
|
185
207
|
try {
|
|
186
208
|
const header = JSON.parse(nip44.decrypt(encryptedHeader, nextSecret)) as Header;
|
|
187
209
|
return [header, true];
|
|
@@ -217,15 +239,27 @@ export class Channel {
|
|
|
217
239
|
|
|
218
240
|
private subscribeToNostrEvents() {
|
|
219
241
|
if (this.nostrNextUnsubscribe) return;
|
|
220
|
-
if (this.state.theirNostrPublicKey) {
|
|
221
|
-
this.nostrUnsubscribe = this.nostrSubscribe(
|
|
222
|
-
{authors: [this.state.theirNostrPublicKey], kinds: [EVENT_KIND]},
|
|
223
|
-
(e) => this.handleNostrEvent(e)
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
242
|
this.nostrNextUnsubscribe = this.nostrSubscribe(
|
|
227
243
|
{authors: [this.state.theirNostrPublicKey], kinds: [EVENT_KIND]},
|
|
228
244
|
(e) => this.handleNostrEvent(e)
|
|
229
245
|
);
|
|
246
|
+
|
|
247
|
+
// Subscribe to all public keys from skipped messages
|
|
248
|
+
const uniquePublicKeys = new Set<string>();
|
|
249
|
+
for (const key of Object.keys(this.state.skippedMessageKeys)) {
|
|
250
|
+
const [pubkey] = key.split(':');
|
|
251
|
+
if (pubkey !== this.state.theirNostrPublicKey) {
|
|
252
|
+
uniquePublicKeys.add(pubkey);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (uniquePublicKeys.size > 0) {
|
|
257
|
+
// do we want this unsubscribed on rotation or should we keep it open
|
|
258
|
+
// in case more skipped messages are found by relays or peers?
|
|
259
|
+
this.nostrUnsubscribe = this.nostrSubscribe(
|
|
260
|
+
{authors: Array.from(uniquePublicKeys), kinds: [EVENT_KIND]},
|
|
261
|
+
(e) => this.handleNostrEvent(e)
|
|
262
|
+
);
|
|
263
|
+
}
|
|
230
264
|
}
|
|
231
265
|
}
|
package/src/InviteLink.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { generateSecretKey, getPublicKey, nip44, finalizeEvent, VerifiedEvent, nip19 } from "nostr-tools";
|
|
2
|
-
import { base58 } from '@scure/base';
|
|
3
2
|
import { NostrSubscribe, Unsubscribe } from "./types";
|
|
4
3
|
import { getConversationKey } from "nostr-tools/nip44";
|
|
5
4
|
import { Channel } from "./Channel";
|
|
6
5
|
import { EVENT_KIND } from "./types";
|
|
7
6
|
import { EncryptFunction, DecryptFunction } from "./types";
|
|
7
|
+
import { hexToBytes, bytesToHex } from "@noble/hashes/utils";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Invite link is a safe way to exchange session keys and initiate secret channels.
|
|
@@ -31,7 +31,7 @@ export class InviteLink {
|
|
|
31
31
|
static createNew(inviter: string, label?: string, maxUses?: number): InviteLink {
|
|
32
32
|
const inviterSessionPrivateKey = generateSecretKey();
|
|
33
33
|
const inviterSessionPublicKey = getPublicKey(inviterSessionPrivateKey);
|
|
34
|
-
const linkSecret =
|
|
34
|
+
const linkSecret = bytesToHex(generateSecretKey());
|
|
35
35
|
return new InviteLink(
|
|
36
36
|
inviterSessionPublicKey,
|
|
37
37
|
linkSecret,
|
|
@@ -127,7 +127,8 @@ export class InviteLink {
|
|
|
127
127
|
const inviteeSessionPublicKey = getPublicKey(inviteeSessionKey);
|
|
128
128
|
const inviterPublicKey = this.inviter || this.inviterSessionPublicKey;
|
|
129
129
|
|
|
130
|
-
const
|
|
130
|
+
const sharedSecret = hexToBytes(this.linkSecret);
|
|
131
|
+
const channel = Channel.init(nostrSubscribe, this.inviterSessionPublicKey, inviteeSessionKey, true, sharedSecret, undefined);
|
|
131
132
|
|
|
132
133
|
// Create a random keypair for the envelope sender
|
|
133
134
|
const randomSenderKey = generateSecretKey();
|
|
@@ -180,9 +181,10 @@ export class InviteLink {
|
|
|
180
181
|
(ciphertext: string, pubkey: string) => Promise.resolve(nip44.decrypt(ciphertext, getConversationKey(inviterSecretKey, pubkey)));
|
|
181
182
|
|
|
182
183
|
const inviteeSessionPublicKey = await innerDecrypt(innerEvent.content, innerEvent.pubkey);
|
|
184
|
+
const sharedSecret = hexToBytes(this.linkSecret);
|
|
183
185
|
|
|
184
186
|
const name = event.id;
|
|
185
|
-
const channel = Channel.init(nostrSubscribe, inviteeSessionPublicKey, this.inviterSessionPrivateKey!,
|
|
187
|
+
const channel = Channel.init(nostrSubscribe, inviteeSessionPublicKey, this.inviterSessionPrivateKey!, false, sharedSecret, name);
|
|
186
188
|
|
|
187
189
|
onChannel(channel, innerEvent.pubkey);
|
|
188
190
|
} catch (error) {
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { VerifiedEvent } from "nostr-tools";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* An event that has been verified to be from the Nostr network.
|
|
5
|
+
*/
|
|
3
6
|
export type Message = {
|
|
4
7
|
id: string;
|
|
5
8
|
data: string;
|
|
@@ -19,13 +22,16 @@ export type NostrFilter = {
|
|
|
19
22
|
kinds?: number[];
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
/**
|
|
26
|
+
* A keypair used for encryption and decryption.
|
|
27
|
+
*/
|
|
22
28
|
export type KeyPair = {
|
|
23
29
|
publicKey: string;
|
|
24
30
|
privateKey: Uint8Array;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
/**
|
|
28
|
-
*
|
|
34
|
+
* State of a Double Ratchet channel between two parties. Needed for persisting channels.
|
|
29
35
|
*/
|
|
30
36
|
export interface ChannelState {
|
|
31
37
|
/** Root key used to derive new sending / receiving chain keys */
|
|
@@ -59,8 +65,20 @@ export interface ChannelState {
|
|
|
59
65
|
skippedMessageKeys: Record<string, Uint8Array>;
|
|
60
66
|
}
|
|
61
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Unsubscribe from a subscription or event listener.
|
|
70
|
+
*/
|
|
62
71
|
export type Unsubscribe = () => void;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Function that subscribes to Nostr events matching a filter and calls onEvent for each event.
|
|
75
|
+
*/
|
|
63
76
|
export type NostrSubscribe = (filter: NostrFilter, onEvent: (e: VerifiedEvent) => void) => Unsubscribe;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Callback function for handling decrypted messages
|
|
80
|
+
* @param message - The decrypted message object
|
|
81
|
+
*/
|
|
64
82
|
export type MessageCallback = (message: Message) => void;
|
|
65
83
|
|
|
66
84
|
export const EVENT_KIND = 4;
|