nostr-double-ratchet 0.0.37 → 0.0.38

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/Invite.ts CHANGED
@@ -1,13 +1,15 @@
1
- import { generateSecretKey, getPublicKey, nip44, finalizeEvent, VerifiedEvent, UnsignedEvent, verifyEvent, Filter } from "nostr-tools";
1
+ import { finalizeEvent, VerifiedEvent, UnsignedEvent, verifyEvent, Filter } from "nostr-tools";
2
2
  import { INVITE_EVENT_KIND, NostrSubscribe, Unsubscribe, EncryptFunction, DecryptFunction, INVITE_RESPONSE_KIND } from "./types";
3
- import { getConversationKey } from "nostr-tools/nip44";
4
3
  import { Session } from "./Session.ts";
5
- import { hexToBytes, bytesToHex } from "@noble/hashes/utils";
6
-
7
- const TWO_DAYS = 2 * 24 * 60 * 60
4
+ import {
5
+ generateEphemeralKeypair,
6
+ generateSharedSecret,
7
+ encryptInviteResponse,
8
+ decryptInviteResponse,
9
+ createSessionFromAccept,
10
+ } from "./inviteUtils";
8
11
 
9
12
  const now = () => Math.round(Date.now() / 1000)
10
- const randomNow = () => Math.round(now() - Math.random() * TWO_DAYS)
11
13
 
12
14
  /**
13
15
  * Invite is a safe way to exchange session keys and initiate secret sessions.
@@ -36,14 +38,13 @@ export class Invite {
36
38
  if (!inviter) {
37
39
  throw new Error("Inviter public key is required");
38
40
  }
39
- const inviterEphemeralPrivateKey = generateSecretKey();
40
- const inviterEphemeralPublicKey = getPublicKey(inviterEphemeralPrivateKey);
41
- const sharedSecret = bytesToHex(generateSecretKey());
41
+ const ephemeralKeypair = generateEphemeralKeypair();
42
+ const sharedSecret = generateSharedSecret();
42
43
  return new Invite(
43
- inviterEphemeralPublicKey,
44
+ ephemeralKeypair.publicKey,
44
45
  sharedSecret,
45
46
  inviter,
46
- inviterEphemeralPrivateKey,
47
+ ephemeralKeypair.privateKey,
47
48
  deviceId,
48
49
  maxUses
49
50
  );
@@ -197,8 +198,9 @@ export class Invite {
197
198
  }
198
199
 
199
200
  /**
200
- * Creates an "invite tombstone" event that clears the original content and removes the list tag.
201
- * Used when the inviter logs out and wants to make the invite invisible to other devices.
201
+ * Creates a tombstone event that replaces the invite, signaling device revocation.
202
+ * The tombstone has the same d-tag but no keys, making it invalid as an invite.
203
+ * Used during migration to InviteList or when revoking a device.
202
204
  */
203
205
  getDeletionEvent(): UnsignedEvent {
204
206
  if (!this.deviceId) {
@@ -207,12 +209,11 @@ export class Invite {
207
209
  return {
208
210
  kind: INVITE_EVENT_KIND,
209
211
  pubkey: this.inviter,
210
- content: "", // deliberately empty
212
+ content: "",
211
213
  created_at: Math.floor(Date.now() / 1000),
212
214
  tags: [
213
- ['ephemeralKey', this.inviterEphemeralPublicKey],
214
- ['sharedSecret', this.sharedSecret],
215
- ['d', 'double-ratchet/invites/' + this.deviceId], // same d tag
215
+ ['d', 'double-ratchet/invites/' + this.deviceId],
216
+ ['l', 'double-ratchet/invites'],
216
217
  ],
217
218
  };
218
219
  }
@@ -240,52 +241,39 @@ export class Invite {
240
241
  encryptor: Uint8Array | EncryptFunction,
241
242
  deviceId?: string,
242
243
  ): Promise<{ session: Session, event: VerifiedEvent }> {
243
- const inviteeSessionKey = generateSecretKey();
244
- const inviteeSessionPublicKey = getPublicKey(inviteeSessionKey);
244
+ const inviteeSessionKeypair = generateEphemeralKeypair();
245
245
  const inviterPublicKey = this.inviter || this.inviterEphemeralPublicKey;
246
246
 
247
- const sharedSecret = hexToBytes(this.sharedSecret);
248
- const session = Session.init(nostrSubscribe, this.inviterEphemeralPublicKey, inviteeSessionKey, true, sharedSecret, undefined);
247
+ const session = createSessionFromAccept({
248
+ nostrSubscribe,
249
+ theirPublicKey: this.inviterEphemeralPublicKey,
250
+ ourSessionPrivateKey: inviteeSessionKeypair.privateKey,
251
+ sharedSecret: this.sharedSecret,
252
+ isSender: true,
253
+ });
249
254
 
250
- // should we take only Encrypt / Decrypt functions, not keys, to make it simpler and with less imports here?
251
- // common implementation problem: plaintext, pubkey params in different order
252
- const encrypt = typeof encryptor === 'function' ?
253
- encryptor :
254
- (plaintext: string, pubkey: string) => Promise.resolve(nip44.encrypt(plaintext, getConversationKey(encryptor, pubkey)));
255
+ const encrypt = typeof encryptor === 'function' ? encryptor : undefined;
256
+ const inviteePrivateKey = typeof encryptor === 'function' ? undefined : encryptor;
255
257
 
256
- const payload = JSON.stringify({
257
- sessionKey: inviteeSessionPublicKey,
258
- deviceId: deviceId
258
+ const encrypted = await encryptInviteResponse({
259
+ inviteeSessionPublicKey: inviteeSessionKeypair.publicKey,
260
+ inviteePublicKey,
261
+ inviteePrivateKey,
262
+ inviterPublicKey,
263
+ inviterEphemeralPublicKey: this.inviterEphemeralPublicKey,
264
+ sharedSecret: this.sharedSecret,
265
+ deviceId,
266
+ encrypt,
259
267
  });
260
- const dhEncrypted = await encrypt(payload, inviterPublicKey);
261
268
 
262
- const innerEvent = {
263
- pubkey: inviteePublicKey,
264
- content: await nip44.encrypt(dhEncrypted, sharedSecret),
265
- created_at: Math.floor(Date.now() / 1000),
266
- };
267
- const innerJson = JSON.stringify(innerEvent);
268
-
269
- // Create a random keypair for the envelope sender
270
- const randomSenderKey = generateSecretKey();
271
- const randomSenderPublicKey = getPublicKey(randomSenderKey);
272
-
273
- const envelope = {
274
- kind: INVITE_RESPONSE_KIND,
275
- pubkey: randomSenderPublicKey,
276
- content: nip44.encrypt(innerJson, getConversationKey(randomSenderKey, this.inviterEphemeralPublicKey)),
277
- created_at: randomNow(),
278
- tags: [['p', this.inviterEphemeralPublicKey]],
279
- };
280
-
281
- return { session, event: finalizeEvent(envelope, randomSenderKey) };
269
+ return { session, event: finalizeEvent(encrypted.envelope, encrypted.randomSenderPrivateKey) };
282
270
  }
283
271
 
284
272
  listen(decryptor: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (_session: Session, _identity: string, _deviceId?: string) => void): Unsubscribe {
285
273
  if (!this.inviterEphemeralPrivateKey) {
286
274
  throw new Error("Inviter session key is not available");
287
275
  }
288
-
276
+
289
277
  const filter = {
290
278
  kinds: [INVITE_RESPONSE_KIND],
291
279
  '#p': [this.inviterEphemeralPublicKey],
@@ -297,39 +285,30 @@ export class Invite {
297
285
  return;
298
286
  }
299
287
 
300
- // Decrypt the outer envelope first
301
- const decrypted = await nip44.decrypt(event.content, getConversationKey(this.inviterEphemeralPrivateKey!, event.pubkey));
302
- const innerEvent = JSON.parse(decrypted);
303
-
304
- const sharedSecret = hexToBytes(this.sharedSecret);
305
- const inviteeIdentity = innerEvent.pubkey;
306
- this.usedBy.push(inviteeIdentity);
307
-
308
- // Decrypt the inner content using shared secret first
309
- const dhEncrypted = await nip44.decrypt(innerEvent.content, sharedSecret);
310
-
311
- // Then decrypt using DH key
312
- const innerDecrypt = typeof decryptor === 'function' ?
313
- decryptor :
314
- (ciphertext: string, pubkey: string) => Promise.resolve(nip44.decrypt(ciphertext, getConversationKey(decryptor, pubkey)));
315
-
316
- const decryptedPayload = await innerDecrypt(dhEncrypted, inviteeIdentity);
317
-
318
- let inviteeSessionPublicKey: string;
319
- let deviceId: string | undefined;
320
-
321
- try {
322
- const parsed = JSON.parse(decryptedPayload);
323
- inviteeSessionPublicKey = parsed.sessionKey;
324
- deviceId = parsed.deviceId;
325
- } catch {
326
- inviteeSessionPublicKey = decryptedPayload;
327
- }
328
-
329
- const name = event.id;
330
- const session = Session.init(nostrSubscribe, inviteeSessionPublicKey, this.inviterEphemeralPrivateKey!, false, sharedSecret, name);
331
-
332
- onSession(session, inviteeIdentity, deviceId);
288
+ const decrypt = typeof decryptor === 'function' ? decryptor : undefined;
289
+ const inviterPrivateKey = typeof decryptor === 'function' ? undefined : decryptor;
290
+
291
+ const decrypted = await decryptInviteResponse({
292
+ envelopeContent: event.content,
293
+ envelopeSenderPubkey: event.pubkey,
294
+ inviterEphemeralPrivateKey: this.inviterEphemeralPrivateKey!,
295
+ inviterPrivateKey,
296
+ sharedSecret: this.sharedSecret,
297
+ decrypt,
298
+ });
299
+
300
+ this.usedBy.push(decrypted.inviteeIdentity);
301
+
302
+ const session = createSessionFromAccept({
303
+ nostrSubscribe,
304
+ theirPublicKey: decrypted.inviteeSessionPublicKey,
305
+ ourSessionPrivateKey: this.inviterEphemeralPrivateKey!,
306
+ sharedSecret: this.sharedSecret,
307
+ isSender: false,
308
+ name: event.id,
309
+ });
310
+
311
+ onSession(session, decrypted.inviteeIdentity, decrypted.deviceId);
333
312
  } catch {
334
313
  }
335
314
  });
@@ -0,0 +1,333 @@
1
+ import { finalizeEvent, VerifiedEvent, UnsignedEvent, verifyEvent } from "nostr-tools"
2
+ import {
3
+ NostrSubscribe,
4
+ Unsubscribe,
5
+ EncryptFunction,
6
+ DecryptFunction,
7
+ INVITE_LIST_EVENT_KIND,
8
+ INVITE_RESPONSE_KIND,
9
+ } from "./types"
10
+ import { Session } from "./Session"
11
+ import {
12
+ generateEphemeralKeypair,
13
+ generateSharedSecret,
14
+ generateDeviceId,
15
+ encryptInviteResponse,
16
+ decryptInviteResponse,
17
+ createSessionFromAccept,
18
+ } from "./inviteUtils"
19
+
20
+ const now = () => Math.round(Date.now() / 1000)
21
+
22
+ type DeviceTag = [
23
+ type: "device",
24
+ ephemeralPublicKey: string,
25
+ sharedSecret: string,
26
+ deviceId: string,
27
+ createdAt: string,
28
+ identityPubkey: string,
29
+ ]
30
+
31
+ type RemovedTag = [type: "removed", deviceId: string]
32
+
33
+ const isDeviceTag = (tag: string[]): tag is DeviceTag =>
34
+ tag.length >= 6 &&
35
+ tag[0] === "device" &&
36
+ tag.slice(1, 6).every((v) => typeof v === "string")
37
+
38
+ const isRemovedTag = (tag: string[]): tag is RemovedTag =>
39
+ tag.length >= 2 &&
40
+ tag[0] === "removed" &&
41
+ typeof tag[1] === "string"
42
+
43
+ export interface DeviceEntry {
44
+ ephemeralPublicKey: string
45
+ /** Only stored locally, not published */
46
+ ephemeralPrivateKey?: Uint8Array
47
+ sharedSecret: string
48
+ deviceId: string
49
+ deviceLabel: string
50
+ createdAt: number
51
+ /** Owner's pubkey for owner devices, delegate's own pubkey for delegate devices */
52
+ identityPubkey: string
53
+ }
54
+
55
+ interface SerializedDeviceEntry extends Omit<DeviceEntry, 'ephemeralPrivateKey'> {
56
+ ephemeralPrivateKey?: number[]
57
+ }
58
+
59
+ /**
60
+ * Manages a consolidated list of device invites (kind 10078).
61
+ * Single atomic event containing all device invites for a user.
62
+ * Uses union merge strategy for conflict resolution.
63
+ */
64
+ export class InviteList {
65
+ private devices: Map<string, DeviceEntry> = new Map()
66
+ private removedDeviceIds: Set<string> = new Set()
67
+
68
+ constructor(
69
+ public readonly ownerPublicKey: string,
70
+ devices: DeviceEntry[] = [],
71
+ removedDeviceIds: string[] = [],
72
+ ) {
73
+ this.removedDeviceIds = new Set(removedDeviceIds)
74
+ devices
75
+ .filter((device) => !this.removedDeviceIds.has(device.deviceId))
76
+ .forEach((device) => this.devices.set(device.deviceId, device))
77
+ }
78
+
79
+ createDevice(label: string, deviceId?: string): DeviceEntry {
80
+ const keypair = generateEphemeralKeypair()
81
+ return {
82
+ ephemeralPublicKey: keypair.publicKey,
83
+ ephemeralPrivateKey: keypair.privateKey,
84
+ sharedSecret: generateSharedSecret(),
85
+ deviceId: deviceId || generateDeviceId(),
86
+ deviceLabel: label,
87
+ createdAt: now(),
88
+ identityPubkey: this.ownerPublicKey,
89
+ }
90
+ }
91
+
92
+ addDevice(device: DeviceEntry): void {
93
+ if (this.removedDeviceIds.has(device.deviceId)) {
94
+ return
95
+ }
96
+ if (!this.devices.has(device.deviceId)) {
97
+ this.devices.set(device.deviceId, device)
98
+ }
99
+ }
100
+
101
+ removeDevice(deviceId: string): void {
102
+ this.devices.delete(deviceId)
103
+ this.removedDeviceIds.add(deviceId)
104
+ }
105
+
106
+ getDevice(deviceId: string): DeviceEntry | undefined {
107
+ return this.devices.get(deviceId)
108
+ }
109
+
110
+ getAllDevices(): DeviceEntry[] {
111
+ return Array.from(this.devices.values())
112
+ }
113
+
114
+ getRemovedDeviceIds(): string[] {
115
+ return Array.from(this.removedDeviceIds)
116
+ }
117
+
118
+ updateDeviceLabel(deviceId: string, newLabel: string): void {
119
+ const device = this.devices.get(deviceId)
120
+ if (device) {
121
+ device.deviceLabel = newLabel
122
+ }
123
+ }
124
+
125
+ getEvent(): UnsignedEvent {
126
+ const deviceTags = this.getAllDevices().map((device) => [
127
+ "device",
128
+ device.ephemeralPublicKey,
129
+ device.sharedSecret,
130
+ device.deviceId,
131
+ String(device.createdAt),
132
+ device.identityPubkey,
133
+ ])
134
+
135
+ const removedTags = this.getRemovedDeviceIds().map((deviceId) => [
136
+ "removed",
137
+ deviceId,
138
+ ])
139
+
140
+ return {
141
+ kind: INVITE_LIST_EVENT_KIND,
142
+ pubkey: this.ownerPublicKey,
143
+ content: "",
144
+ created_at: now(),
145
+ tags: [
146
+ ["d", "double-ratchet/invite-list"],
147
+ ["version", "1"],
148
+ ...deviceTags,
149
+ ...removedTags,
150
+ ],
151
+ }
152
+ }
153
+
154
+ static fromEvent(event: VerifiedEvent): InviteList {
155
+ if (!event.sig) {
156
+ throw new Error("Event is not signed")
157
+ }
158
+ if (!verifyEvent(event)) {
159
+ throw new Error("Event signature is invalid")
160
+ }
161
+
162
+ const devices = event.tags
163
+ .filter(isDeviceTag)
164
+ .map(([, ephemeralPublicKey, sharedSecret, deviceId, createdAt, identityPubkey]) => ({
165
+ ephemeralPublicKey,
166
+ sharedSecret,
167
+ deviceId,
168
+ deviceLabel: deviceId,
169
+ createdAt: parseInt(createdAt, 10) || event.created_at,
170
+ identityPubkey,
171
+ }))
172
+
173
+ const removedDeviceIds = event.tags
174
+ .filter(isRemovedTag)
175
+ .map(([, deviceId]) => deviceId)
176
+
177
+ return new InviteList(event.pubkey, devices, removedDeviceIds)
178
+ }
179
+
180
+ serialize(): string {
181
+ const devices = this.getAllDevices().map((d) => ({
182
+ ...d,
183
+ ephemeralPrivateKey: d.ephemeralPrivateKey
184
+ ? Array.from(d.ephemeralPrivateKey)
185
+ : undefined,
186
+ }))
187
+
188
+ return JSON.stringify({
189
+ ownerPublicKey: this.ownerPublicKey,
190
+ devices,
191
+ removedDeviceIds: this.getRemovedDeviceIds(),
192
+ })
193
+ }
194
+
195
+ static deserialize(json: string): InviteList {
196
+ const data = JSON.parse(json) as { ownerPublicKey: string; devices: SerializedDeviceEntry[]; removedDeviceIds?: string[] }
197
+ const devices: DeviceEntry[] = data.devices.map((d) => ({
198
+ ...d,
199
+ ephemeralPrivateKey: d.ephemeralPrivateKey
200
+ ? new Uint8Array(d.ephemeralPrivateKey)
201
+ : undefined,
202
+ }))
203
+
204
+ return new InviteList(data.ownerPublicKey, devices, data.removedDeviceIds || [])
205
+ }
206
+
207
+ merge(other: InviteList): InviteList {
208
+ const mergedRemovedIds = new Set([
209
+ ...this.removedDeviceIds,
210
+ ...other.removedDeviceIds,
211
+ ])
212
+
213
+ const mergedDevices = [...this.devices.values(), ...other.devices.values()]
214
+ .reduce((map, device) => {
215
+ const existing = map.get(device.deviceId)
216
+ map.set(device.deviceId, existing
217
+ ? { ...device, ephemeralPrivateKey: existing.ephemeralPrivateKey || device.ephemeralPrivateKey }
218
+ : device
219
+ )
220
+ return map
221
+ }, new Map<string, DeviceEntry>())
222
+
223
+ const activeDevices = Array.from(mergedDevices.values())
224
+ .filter((device) => !mergedRemovedIds.has(device.deviceId))
225
+
226
+ return new InviteList(
227
+ this.ownerPublicKey,
228
+ activeDevices,
229
+ Array.from(mergedRemovedIds)
230
+ )
231
+ }
232
+
233
+ async accept(
234
+ deviceId: string,
235
+ nostrSubscribe: NostrSubscribe,
236
+ inviteePublicKey: string,
237
+ encryptor: Uint8Array | EncryptFunction,
238
+ inviteeDeviceId?: string
239
+ ): Promise<{ session: Session; event: VerifiedEvent }> {
240
+ const device = this.devices.get(deviceId)
241
+ if (!device) {
242
+ throw new Error(`Device ${deviceId} not found in invite list`)
243
+ }
244
+
245
+ const inviteeSessionKeypair = generateEphemeralKeypair()
246
+
247
+ const session = createSessionFromAccept({
248
+ nostrSubscribe,
249
+ theirPublicKey: device.ephemeralPublicKey,
250
+ ourSessionPrivateKey: inviteeSessionKeypair.privateKey,
251
+ sharedSecret: device.sharedSecret,
252
+ isSender: true,
253
+ })
254
+
255
+ const encrypt = typeof encryptor === "function" ? encryptor : undefined
256
+ const inviteePrivateKey = typeof encryptor === "function" ? undefined : encryptor
257
+
258
+ const encrypted = await encryptInviteResponse({
259
+ inviteeSessionPublicKey: inviteeSessionKeypair.publicKey,
260
+ inviteePublicKey,
261
+ inviteePrivateKey,
262
+ inviterPublicKey: device.identityPubkey,
263
+ inviterEphemeralPublicKey: device.ephemeralPublicKey,
264
+ sharedSecret: device.sharedSecret,
265
+ deviceId: inviteeDeviceId,
266
+ encrypt,
267
+ })
268
+
269
+ return {
270
+ session,
271
+ event: finalizeEvent(encrypted.envelope, encrypted.randomSenderPrivateKey),
272
+ }
273
+ }
274
+
275
+ listen(
276
+ decryptor: Uint8Array | DecryptFunction,
277
+ nostrSubscribe: NostrSubscribe,
278
+ onSession: (
279
+ session: Session,
280
+ identity: string,
281
+ deviceId?: string,
282
+ ourDeviceId?: string
283
+ ) => void
284
+ ): Unsubscribe {
285
+ const devices = this.getAllDevices()
286
+ const decryptableDevices = devices.filter((d) => !!d.ephemeralPrivateKey)
287
+
288
+ // If we don't have any devices we can decrypt for, do nothing gracefully
289
+ if (decryptableDevices.length === 0) {
290
+ return () => {}
291
+ }
292
+
293
+ const ephemeralPubkeys = decryptableDevices.map((d) => d.ephemeralPublicKey)
294
+ const decrypt = typeof decryptor === "function" ? decryptor : undefined
295
+ const ownerPrivateKey = typeof decryptor === "function" ? undefined : decryptor
296
+
297
+ return nostrSubscribe(
298
+ { kinds: [INVITE_RESPONSE_KIND], "#p": ephemeralPubkeys },
299
+ async (event) => {
300
+ const targetPubkey = event.tags.find((t) => t[0] === "p")?.[1]
301
+ const device = decryptableDevices.find((d) => d.ephemeralPublicKey === targetPubkey)
302
+
303
+ if (!device || !device.ephemeralPrivateKey) {
304
+ return
305
+ }
306
+
307
+ try {
308
+ const decrypted = await decryptInviteResponse({
309
+ envelopeContent: event.content,
310
+ envelopeSenderPubkey: event.pubkey,
311
+ inviterEphemeralPrivateKey: device.ephemeralPrivateKey,
312
+ inviterPrivateKey: ownerPrivateKey,
313
+ sharedSecret: device.sharedSecret,
314
+ decrypt,
315
+ })
316
+
317
+ const session = createSessionFromAccept({
318
+ nostrSubscribe,
319
+ theirPublicKey: decrypted.inviteeSessionPublicKey,
320
+ ourSessionPrivateKey: device.ephemeralPrivateKey,
321
+ sharedSecret: device.sharedSecret,
322
+ isSender: false,
323
+ name: event.id,
324
+ })
325
+
326
+ onSession(session, decrypted.inviteeIdentity, decrypted.deviceId, device.deviceId)
327
+ } catch {
328
+ // Invalid response
329
+ }
330
+ }
331
+ )
332
+ }
333
+ }
package/src/Session.ts CHANGED
@@ -393,7 +393,7 @@ export class Session {
393
393
  this.nostrUnsubscribe = this.nostrSubscribe(
394
394
  {authors: [this.state.theirCurrentNostrPublicKey], kinds: [MESSAGE_EVENT_KIND]},
395
395
  (e) => this.handleNostrEvent(e)
396
- );
396
+ );
397
397
  }
398
398
 
399
399
  const skippedAuthors = Object.keys(this.state.skippedKeys);