nostr-double-ratchet 0.0.7 → 0.0.9

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.
@@ -1,23 +1,22 @@
1
- import { generateSecretKey, getPublicKey, nip44, finalizeEvent, VerifiedEvent, nip19 } from "nostr-tools";
2
- import { NostrSubscribe, Unsubscribe } from "./types";
1
+ import { generateSecretKey, getPublicKey, nip44, finalizeEvent, VerifiedEvent, nip19, UnsignedEvent, verifyEvent, Filter } from "nostr-tools";
2
+ import { INVITE_EVENT_KIND, NostrSubscribe, Unsubscribe } from "./types";
3
3
  import { getConversationKey } from "nostr-tools/nip44";
4
4
  import { Channel } from "./Channel";
5
- import { EVENT_KIND } from "./types";
5
+ import { MESSAGE_EVENT_KIND } from "./types";
6
6
  import { EncryptFunction, DecryptFunction } from "./types";
7
7
  import { hexToBytes, bytesToHex } from "@noble/hashes/utils";
8
8
 
9
9
  /**
10
- * Invite link is a safe way to exchange session keys and initiate secret channels.
10
+ * Invite is a safe way to exchange session keys and initiate secret channels.
11
+ *
12
+ * It can be shared privately as an URL (e.g. QR code) or published as a Nostr event.
11
13
  *
12
14
  * Even if inviter's or invitee's long-term private key (identity key) and the shared secret (link) is compromised,
13
15
  * forward secrecy is preserved as long as the session keys are not compromised.
14
16
  *
15
- * Shared secret Nostr channel: inviter listens to it and invitees can write to it. Outside observers don't know who are communicating over it.
16
- * It is vulnerable to spam, so the link should only be given to trusted invitees or used with a reasonable maxUses limit.
17
- *
18
17
  * Also make sure to keep the session key safe.
19
18
  */
20
- export class InviteLink {
19
+ export class Invite {
21
20
  constructor(
22
21
  public inviterSessionPublicKey: string,
23
22
  public linkSecret: string,
@@ -28,11 +27,11 @@ export class InviteLink {
28
27
  public usedBy: string[] = [],
29
28
  ) {}
30
29
 
31
- static createNew(inviter: string, label?: string, maxUses?: number): InviteLink {
30
+ static createNew(inviter: string, label?: string, maxUses?: number): Invite {
32
31
  const inviterSessionPrivateKey = generateSecretKey();
33
32
  const inviterSessionPublicKey = getPublicKey(inviterSessionPrivateKey);
34
33
  const linkSecret = bytesToHex(generateSecretKey());
35
- return new InviteLink(
34
+ return new Invite(
36
35
  inviterSessionPublicKey,
37
36
  linkSecret,
38
37
  inviter,
@@ -42,7 +41,7 @@ export class InviteLink {
42
41
  );
43
42
  }
44
43
 
45
- static fromUrl(url: string): InviteLink {
44
+ static fromUrl(url: string): Invite {
46
45
  const parsedUrl = new URL(url);
47
46
  const rawHash = parsedUrl.hash.slice(1);
48
47
  if (!rawHash) {
@@ -72,16 +71,16 @@ export class InviteLink {
72
71
  throw new Error("Decoded session key is not a string");
73
72
  }
74
73
 
75
- return new InviteLink(
74
+ return new Invite(
76
75
  decodedSessionKey.data,
77
76
  linkSecret,
78
77
  decodedInviter.data
79
78
  );
80
79
  }
81
80
 
82
- static deserialize(json: string): InviteLink {
81
+ static deserialize(json: string): Invite {
83
82
  const data = JSON.parse(json);
84
- return new InviteLink(
83
+ return new Invite(
85
84
  data.inviterSessionPublicKey,
86
85
  data.linkSecret,
87
86
  data.inviter,
@@ -92,6 +91,59 @@ export class InviteLink {
92
91
  );
93
92
  }
94
93
 
94
+ static fromEvent(event: VerifiedEvent): Invite {
95
+ if (!event.sig) {
96
+ throw new Error("Event is not signed");
97
+ }
98
+ if (!verifyEvent(event)) {
99
+ throw new Error("Event signature is invalid");
100
+ }
101
+ const { tags } = event;
102
+ const inviterSessionPublicKey = tags.find(([key]) => key === 'sessionKey')?.[1];
103
+ const linkSecret = tags.find(([key]) => key === 'linkSecret')?.[1];
104
+ const inviter = event.pubkey;
105
+
106
+ if (!inviterSessionPublicKey || !linkSecret) {
107
+ throw new Error("Invalid invite event: missing session key or link secret");
108
+ }
109
+
110
+ return new Invite(
111
+ inviterSessionPublicKey,
112
+ linkSecret,
113
+ inviter
114
+ );
115
+ }
116
+
117
+ static fromUser(user: string, subscribe: NostrSubscribe): Promise<Invite | undefined> {
118
+ const filter: Filter = {
119
+ kinds: [INVITE_EVENT_KIND],
120
+ authors: [user],
121
+ limit: 1,
122
+ "#d": ["nostr-double-ratchet/invite"],
123
+ };
124
+ return new Promise((resolve) => {
125
+ const unsub = subscribe(filter, (event) => {
126
+ try {
127
+ const inviteLink = Invite.fromEvent(event);
128
+ unsub();
129
+ resolve(inviteLink);
130
+ } catch (error) {
131
+ unsub();
132
+ resolve(undefined);
133
+ }
134
+ });
135
+
136
+ // Set timeout to unsubscribe and return undefined after 10 seconds
137
+ setTimeout(() => {
138
+ unsub();
139
+ resolve(undefined);
140
+ }, 10000);
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Save Invite as JSON. Includes the inviter's session private key, so don't share this.
146
+ */
95
147
  serialize(): string {
96
148
  return JSON.stringify({
97
149
  inviterSessionPublicKey: this.inviterSessionPublicKey,
@@ -104,6 +156,9 @@ export class InviteLink {
104
156
  });
105
157
  }
106
158
 
159
+ /**
160
+ * Invite parameters are in the URL's hash so they are not sent to the server.
161
+ */
107
162
  getUrl(root = "https://iris.to") {
108
163
  const data = {
109
164
  inviter: nip19.npubEncode(this.inviter),
@@ -116,8 +171,18 @@ export class InviteLink {
116
171
  return url.toString();
117
172
  }
118
173
 
174
+ getEvent(): UnsignedEvent {
175
+ return {
176
+ kind: INVITE_EVENT_KIND,
177
+ pubkey: this.inviter,
178
+ content: "",
179
+ created_at: Math.floor(Date.now() / 1000),
180
+ tags: [['sessionKey', this.inviterSessionPublicKey], ['linkSecret', this.linkSecret], ['d', 'nostr-double-ratchet/invite']],
181
+ };
182
+ }
183
+
119
184
  /**
120
- * Accepts the invite and creates a new channel with the inviter.
185
+ * Called by the invitee. Accepts the invite and creates a new channel with the inviter.
121
186
  *
122
187
  * @param inviteeSecretKey - The invitee's secret key or a signing function
123
188
  * @param nostrSubscribe - A function to subscribe to Nostr events
@@ -131,7 +196,7 @@ export class InviteLink {
131
196
  * Note: You need to publish the returned event on Nostr using NDK or another Nostr system of your choice,
132
197
  * so the inviter can create the channel on their side.
133
198
  */
134
- async acceptInvite(
199
+ async accept(
135
200
  nostrSubscribe: NostrSubscribe,
136
201
  inviteePublicKey: string,
137
202
  inviteeSecretKey: Uint8Array | EncryptFunction,
@@ -159,7 +224,7 @@ export class InviteLink {
159
224
  };
160
225
 
161
226
  const envelope = {
162
- kind: EVENT_KIND,
227
+ kind: MESSAGE_EVENT_KIND,
163
228
  pubkey: randomSenderPublicKey,
164
229
  content: nip44.encrypt(JSON.stringify(innerEvent), getConversationKey(randomSenderKey, this.inviterSessionPublicKey)),
165
230
  created_at: Math.floor(Date.now() / 1000),
@@ -175,12 +240,17 @@ export class InviteLink {
175
240
  }
176
241
 
177
242
  const filter = {
178
- kinds: [EVENT_KIND],
243
+ kinds: [MESSAGE_EVENT_KIND],
179
244
  '#p': [this.inviterSessionPublicKey],
180
245
  };
181
246
 
182
247
  return nostrSubscribe(filter, async (event) => {
183
248
  try {
249
+ if (this.maxUses && this.usedBy.length >= this.maxUses) {
250
+ console.error("Invite has reached maximum number of uses");
251
+ return;
252
+ }
253
+
184
254
  const decrypted = await nip44.decrypt(event.content, getConversationKey(this.inviterSessionPrivateKey!, event.pubkey));
185
255
  const innerEvent = JSON.parse(decrypted);
186
256
 
@@ -193,13 +263,16 @@ export class InviteLink {
193
263
  inviterSecretKey :
194
264
  (ciphertext: string, pubkey: string) => Promise.resolve(nip44.decrypt(ciphertext, getConversationKey(inviterSecretKey, pubkey)));
195
265
 
196
- const inviteeSessionPublicKey = await innerDecrypt(innerEvent.content, innerEvent.pubkey);
266
+ const inviteeIdentity = innerEvent.pubkey;
267
+ this.usedBy.push(inviteeIdentity);
268
+
269
+ const inviteeSessionPublicKey = await innerDecrypt(innerEvent.content, inviteeIdentity);
197
270
  const sharedSecret = hexToBytes(this.linkSecret);
198
271
 
199
272
  const name = event.id;
200
273
  const channel = Channel.init(nostrSubscribe, inviteeSessionPublicKey, this.inviterSessionPrivateKey!, false, sharedSecret, name);
201
274
 
202
- onChannel(channel, innerEvent.pubkey);
275
+ onChannel(channel, inviteeIdentity);
203
276
  } catch (error) {
204
277
  console.error("Error processing invite message:", error);
205
278
  }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export * from "./Channel"
2
- export * from "./InviteLink"
2
+ export * from "./Invite"
3
3
  export * from "./types"
4
4
  export * from "./utils"
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { VerifiedEvent } from "nostr-tools";
1
+ import { Filter, UnsignedEvent, VerifiedEvent } from "nostr-tools";
2
2
 
3
3
  /**
4
4
  * An event that has been verified to be from the Nostr network.
@@ -17,11 +17,6 @@ export type Header = {
17
17
  time: number;
18
18
  }
19
19
 
20
- export type NostrFilter = {
21
- authors?: string[];
22
- kinds?: number[];
23
- }
24
-
25
20
  /**
26
21
  * A keypair used for encryption and decryption.
27
22
  */
@@ -76,7 +71,7 @@ export type Unsubscribe = () => void;
76
71
  /**
77
72
  * Function that subscribes to Nostr events matching a filter and calls onEvent for each event.
78
73
  */
79
- export type NostrSubscribe = (filter: NostrFilter, onEvent: (e: VerifiedEvent) => void) => Unsubscribe;
74
+ export type NostrSubscribe = (filter: Filter, onEvent: (e: VerifiedEvent) => void) => Unsubscribe;
80
75
 
81
76
  /**
82
77
  * Callback function for handling decrypted messages
@@ -84,7 +79,8 @@ export type NostrSubscribe = (filter: NostrFilter, onEvent: (e: VerifiedEvent) =
84
79
  */
85
80
  export type MessageCallback = (message: Message) => void;
86
81
 
87
- export const EVENT_KIND = 4;
82
+ export const MESSAGE_EVENT_KIND = 4;
83
+ export const INVITE_EVENT_KIND = 30078;
88
84
  export const MAX_SKIP = 100;
89
85
 
90
86
  export type NostrEvent = {
@@ -103,4 +99,5 @@ export enum Sender {
103
99
  }
104
100
 
105
101
  export type EncryptFunction = (plaintext: string, pubkey: string) => Promise<string>;
106
- export type DecryptFunction = (ciphertext: string, pubkey: string) => Promise<string>;
102
+ export type DecryptFunction = (ciphertext: string, pubkey: string) => Promise<string>;
103
+ export type NostrPublish = (event: UnsignedEvent) => Promise<VerifiedEvent>;
@@ -1 +0,0 @@
1
- {"version":3,"file":"InviteLink.d.ts","sourceRoot":"","sources":["../src/InviteLink.ts"],"names":[],"mappings":"AAAA,OAAO,EAAyD,aAAa,EAAS,MAAM,aAAa,CAAC;AAC1G,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAEtD,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAG3D;;;;;;;;;;GAUG;AACH,qBAAa,UAAU;IAER,uBAAuB,EAAE,MAAM;IAC/B,UAAU,EAAE,MAAM;IAClB,OAAO,EAAE,MAAM;IACf,wBAAwB,CAAC,EAAE,UAAU;IACrC,KAAK,CAAC,EAAE,MAAM;IACd,OAAO,CAAC,EAAE,MAAM;IAChB,MAAM,EAAE,MAAM,EAAE;gBANhB,uBAAuB,EAAE,MAAM,EAC/B,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,EACf,wBAAwB,CAAC,EAAE,UAAU,YAAA,EACrC,KAAK,CAAC,EAAE,MAAM,YAAA,EACd,OAAO,CAAC,EAAE,MAAM,YAAA,EAChB,MAAM,GAAE,MAAM,EAAO;IAGhC,MAAM,CAAC,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,UAAU;IAc/E,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU;IAqCvC,MAAM,CAAC,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU;IAa5C,SAAS,IAAI,MAAM;IAYnB,MAAM,CAAC,IAAI,SAAoB;IAY/B;;;;;;;;;;;;;;OAcG;IACG,YAAY,CACd,cAAc,EAAE,cAAc,EAC9B,gBAAgB,EAAE,MAAM,EACxB,gBAAgB,EAAE,UAAU,GAAG,eAAe,GAC/C,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,aAAa,CAAA;KAAE,CAAC;IAkCtD,MAAM,CAAC,gBAAgB,EAAE,UAAU,GAAG,eAAe,EAAE,cAAc,EAAE,cAAc,EAAE,SAAS,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,MAAM,KAAK,IAAI,GAAG,WAAW;CAoChK"}