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/src/Channel.ts CHANGED
@@ -13,7 +13,9 @@ import { kdf, skippedMessageIndexKey } from "./utils";
13
13
  const MAX_SKIP = 1000;
14
14
 
15
15
  /**
16
- * Similar to Signal's "Double Ratchet with header encryption"
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
- * @param sharedSecret optional, but useful to keep the first chain of messages secure. Unlike the Nostr keys, it can be forgotten after the 1st message in the chain.
32
- * @param isInitiator determines which chain key is used for sending vs receiving
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, sharedSecret = new Uint8Array(), name?: string, isInitiator = true): Channel {
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 [intermediateRootKey, receivingChainKey] = kdf(this.state.rootKey, conversationKey1, 3);
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 [rootKey2, sendingChainKey] = kdf(intermediateRootKey, conversationKey2, 3);
145
- this.state.rootKey = rootKey2;
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
- private decryptHeader(e: any): [Header, boolean] {
173
- const encryptedHeader = e.tags[0][1];
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, e.pubkey);
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, e.pubkey);
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 = base58.encode(generateSecretKey()).slice(8, 40);
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 channel = Channel.init(nostrSubscribe, this.inviterSessionPublicKey, inviteeSessionKey, new Uint8Array(), undefined, true);
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!, new Uint8Array(), name, false);
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
- * Represents the state of a Double Ratchet channel between two parties. Needed for persisting channels.
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;