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.
- package/README.md +2 -1
- package/dist/Channel.d.ts.map +1 -1
- package/dist/{InviteLink.d.ts → Invite.d.ts} +20 -12
- package/dist/Invite.d.ts.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/nostr-double-ratchet.es.js +1205 -1151
- package/dist/nostr-double-ratchet.umd.js +1 -1
- package/dist/types.d.ts +5 -7
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/Channel.ts +7 -15
- package/src/{InviteLink.ts → Invite.ts} +93 -20
- package/src/index.ts +1 -1
- package/src/types.ts +6 -9
- package/dist/InviteLink.d.ts.map +0 -1
|
@@ -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 {
|
|
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
|
|
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
|
|
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):
|
|
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
|
|
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):
|
|
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
|
|
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):
|
|
81
|
+
static deserialize(json: string): Invite {
|
|
83
82
|
const data = JSON.parse(json);
|
|
84
|
-
return new
|
|
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
|
|
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:
|
|
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: [
|
|
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
|
|
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,
|
|
275
|
+
onChannel(channel, inviteeIdentity);
|
|
203
276
|
} catch (error) {
|
|
204
277
|
console.error("Error processing invite message:", error);
|
|
205
278
|
}
|
package/src/index.ts
CHANGED
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:
|
|
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
|
|
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>;
|
package/dist/InviteLink.d.ts.map
DELETED
|
@@ -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"}
|