nostr-double-ratchet 0.0.37 → 0.0.48
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 +52 -15
- package/dist/AppKeys.d.ts +52 -0
- package/dist/AppKeys.d.ts.map +1 -0
- package/dist/AppKeysManager.d.ts +136 -0
- package/dist/AppKeysManager.d.ts.map +1 -0
- package/dist/Invite.d.ts +7 -7
- package/dist/Invite.d.ts.map +1 -1
- package/dist/Session.d.ts +29 -0
- package/dist/Session.d.ts.map +1 -1
- package/dist/SessionManager.d.ts +46 -22
- package/dist/SessionManager.d.ts.map +1 -1
- package/dist/StorageAdapter.d.ts.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/inviteUtils.d.ts +122 -0
- package/dist/inviteUtils.d.ts.map +1 -0
- package/dist/nostr-double-ratchet.es.js +2843 -1901
- package/dist/nostr-double-ratchet.umd.js +1 -1
- package/dist/types.d.ts +32 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +20 -2
- package/dist/utils.d.ts.map +1 -1
- package/package.json +5 -19
- package/src/AppKeys.ts +210 -0
- package/src/AppKeysManager.ts +405 -0
- package/src/Invite.ts +69 -89
- package/src/Session.ts +47 -4
- package/src/SessionManager.ts +478 -300
- package/src/StorageAdapter.ts +5 -6
- package/src/index.ts +4 -1
- package/src/inviteUtils.ts +271 -0
- package/src/types.ts +37 -2
- package/src/utils.ts +45 -8
- package/LICENSE +0 -21
- package/dist/UserRecord.d.ts +0 -117
- package/dist/UserRecord.d.ts.map +0 -1
- package/src/UserRecord.ts +0 -338
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { generateSecretKey, getPublicKey, finalizeEvent } from "nostr-tools"
|
|
2
|
+
import { AppKeys, DeviceEntry } from "./AppKeys"
|
|
3
|
+
import { Invite } from "./Invite"
|
|
4
|
+
import { NostrSubscribe, NostrPublish, APP_KEYS_EVENT_KIND, Unsubscribe } from "./types"
|
|
5
|
+
import { StorageAdapter, InMemoryStorageAdapter } from "./StorageAdapter"
|
|
6
|
+
import { SessionManager } from "./SessionManager"
|
|
7
|
+
|
|
8
|
+
export interface DelegatePayload {
|
|
9
|
+
identityPubkey: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Options for AppKeysManager (authority for AppKeys)
|
|
14
|
+
*/
|
|
15
|
+
export interface AppKeysManagerOptions {
|
|
16
|
+
nostrPublish: NostrPublish
|
|
17
|
+
storage?: StorageAdapter
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Options for DelegateManager (device identity)
|
|
22
|
+
*/
|
|
23
|
+
export interface DelegateManagerOptions {
|
|
24
|
+
nostrSubscribe: NostrSubscribe
|
|
25
|
+
nostrPublish: NostrPublish
|
|
26
|
+
storage?: StorageAdapter
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* AppKeysManager - Authority for AppKeys.
|
|
32
|
+
* Manages local AppKeys and publishes to relays.
|
|
33
|
+
* Does NOT have device identity (no Invite, no SessionManager creation).
|
|
34
|
+
*/
|
|
35
|
+
export class AppKeysManager {
|
|
36
|
+
private readonly nostrPublish: NostrPublish
|
|
37
|
+
private readonly storage: StorageAdapter
|
|
38
|
+
|
|
39
|
+
private appKeys: AppKeys | null = null
|
|
40
|
+
private initialized = false
|
|
41
|
+
|
|
42
|
+
private readonly storageVersion = "3"
|
|
43
|
+
private get versionPrefix(): string {
|
|
44
|
+
return `v${this.storageVersion}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
constructor(options: AppKeysManagerOptions) {
|
|
48
|
+
this.nostrPublish = options.nostrPublish
|
|
49
|
+
this.storage = options.storage || new InMemoryStorageAdapter()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async init(): Promise<void> {
|
|
53
|
+
if (this.initialized) return
|
|
54
|
+
this.initialized = true
|
|
55
|
+
|
|
56
|
+
// Load local only - no auto-subscribe, no auto-publish, no auto-merge
|
|
57
|
+
this.appKeys = await this.loadAppKeys()
|
|
58
|
+
if (!this.appKeys) {
|
|
59
|
+
this.appKeys = new AppKeys()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
getAppKeys(): AppKeys | null {
|
|
64
|
+
return this.appKeys
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
getOwnDevices(): DeviceEntry[] {
|
|
68
|
+
return this.appKeys?.getAllDevices() || []
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Add a device to the AppKeys.
|
|
73
|
+
* Only adds identity info - the device publishes its own Invite separately.
|
|
74
|
+
* This is a local-only operation - call publish() to publish to relays.
|
|
75
|
+
*/
|
|
76
|
+
addDevice(payload: DelegatePayload): void {
|
|
77
|
+
if (!this.appKeys) {
|
|
78
|
+
this.appKeys = new AppKeys()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const device: DeviceEntry = {
|
|
82
|
+
identityPubkey: payload.identityPubkey,
|
|
83
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
84
|
+
}
|
|
85
|
+
this.appKeys.addDevice(device)
|
|
86
|
+
this.saveAppKeys(this.appKeys).catch(() => {})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Revoke a device from the AppKeys.
|
|
91
|
+
* This is a local-only operation - call publish() to publish to relays.
|
|
92
|
+
*/
|
|
93
|
+
revokeDevice(identityPubkey: string): void {
|
|
94
|
+
if (!this.appKeys) return
|
|
95
|
+
|
|
96
|
+
this.appKeys.removeDevice(identityPubkey)
|
|
97
|
+
this.saveAppKeys(this.appKeys).catch(() => {})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Publish the current AppKeys to relays.
|
|
102
|
+
* This is the only way to publish - addDevice/revokeDevice are local-only.
|
|
103
|
+
*/
|
|
104
|
+
async publish(): Promise<void> {
|
|
105
|
+
if (!this.appKeys) {
|
|
106
|
+
this.appKeys = new AppKeys()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const event = this.appKeys.getEvent()
|
|
110
|
+
await this.nostrPublish(event)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Replace the local AppKeys with the given list and save to storage.
|
|
115
|
+
* Used for authority transfer - receive list from another device, then call publish().
|
|
116
|
+
*/
|
|
117
|
+
async setAppKeys(list: AppKeys): Promise<void> {
|
|
118
|
+
this.appKeys = list
|
|
119
|
+
await this.saveAppKeys(list)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Cleanup resources. Currently a no-op but kept for API consistency.
|
|
124
|
+
*/
|
|
125
|
+
close(): void {
|
|
126
|
+
// No-op - no subscriptions to clean up
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private appKeysKey(): string {
|
|
130
|
+
return `${this.versionPrefix}/app-keys-manager/app-keys`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async loadAppKeys(): Promise<AppKeys | null> {
|
|
134
|
+
const data = await this.storage.get<string>(this.appKeysKey())
|
|
135
|
+
if (!data) return null
|
|
136
|
+
try {
|
|
137
|
+
return AppKeys.deserialize(data)
|
|
138
|
+
} catch {
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async saveAppKeys(list: AppKeys): Promise<void> {
|
|
144
|
+
await this.storage.put(this.appKeysKey(), list.serialize())
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* DelegateManager - Device identity manager.
|
|
150
|
+
* ALL devices (including main) use this for their device identity.
|
|
151
|
+
* Publishes own Invite events, used for SessionManager DH encryption.
|
|
152
|
+
*/
|
|
153
|
+
export class DelegateManager {
|
|
154
|
+
private readonly nostrSubscribe: NostrSubscribe
|
|
155
|
+
private readonly nostrPublish: NostrPublish
|
|
156
|
+
private readonly storage: StorageAdapter
|
|
157
|
+
|
|
158
|
+
private devicePublicKey: string = ""
|
|
159
|
+
private devicePrivateKey: Uint8Array = new Uint8Array()
|
|
160
|
+
|
|
161
|
+
private invite: Invite | null = null
|
|
162
|
+
private ownerPubkeyFromActivation?: string
|
|
163
|
+
private initialized = false
|
|
164
|
+
private subscriptions: Unsubscribe[] = []
|
|
165
|
+
|
|
166
|
+
private readonly storageVersion = "1"
|
|
167
|
+
private get versionPrefix(): string {
|
|
168
|
+
return `v${this.storageVersion}`
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
constructor(options: DelegateManagerOptions) {
|
|
172
|
+
this.nostrSubscribe = options.nostrSubscribe
|
|
173
|
+
this.nostrPublish = options.nostrPublish
|
|
174
|
+
this.storage = options.storage || new InMemoryStorageAdapter()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async init(): Promise<void> {
|
|
178
|
+
if (this.initialized) return
|
|
179
|
+
this.initialized = true
|
|
180
|
+
|
|
181
|
+
// Load or generate identity keys
|
|
182
|
+
const storedPublicKey = await this.storage.get<string>(this.identityPublicKeyKey())
|
|
183
|
+
const storedPrivateKey = await this.storage.get<number[]>(this.identityPrivateKeyKey())
|
|
184
|
+
|
|
185
|
+
if (storedPublicKey && storedPrivateKey) {
|
|
186
|
+
this.devicePublicKey = storedPublicKey
|
|
187
|
+
this.devicePrivateKey = new Uint8Array(storedPrivateKey)
|
|
188
|
+
} else {
|
|
189
|
+
this.devicePrivateKey = generateSecretKey()
|
|
190
|
+
this.devicePublicKey = getPublicKey(this.devicePrivateKey)
|
|
191
|
+
await this.storage.put(this.identityPublicKeyKey(), this.devicePublicKey)
|
|
192
|
+
await this.storage.put(this.identityPrivateKeyKey(), Array.from(this.devicePrivateKey))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const storedOwnerPubkey = await this.storage.get<string>(this.ownerPubkeyKey())
|
|
196
|
+
if (storedOwnerPubkey) {
|
|
197
|
+
this.ownerPubkeyFromActivation = storedOwnerPubkey
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Load or create Invite for this device
|
|
201
|
+
const savedInvite = await this.loadInvite()
|
|
202
|
+
this.invite = savedInvite || Invite.createNew(this.devicePublicKey, this.devicePublicKey)
|
|
203
|
+
await this.saveInvite(this.invite)
|
|
204
|
+
|
|
205
|
+
// Sign and publish Invite event with this device's identity key
|
|
206
|
+
const inviteEvent = this.invite.getEvent()
|
|
207
|
+
const signedInvite = finalizeEvent(inviteEvent, this.devicePrivateKey)
|
|
208
|
+
await this.nostrPublish(signedInvite).catch(() => {
|
|
209
|
+
// Failed to publish Invite
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get the registration payload for adding this device to an AppKeysManager.
|
|
215
|
+
* Must be called after init().
|
|
216
|
+
*/
|
|
217
|
+
getRegistrationPayload(): DelegatePayload {
|
|
218
|
+
return { identityPubkey: this.devicePublicKey }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
getIdentityPublicKey(): string {
|
|
222
|
+
return this.devicePublicKey
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
getIdentityKey(): Uint8Array {
|
|
226
|
+
return this.devicePrivateKey
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
getInvite(): Invite | null {
|
|
230
|
+
return this.invite
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
getOwnerPublicKey(): string | null {
|
|
234
|
+
return this.ownerPubkeyFromActivation || null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Rotate this device's invite - generates new ephemeral keys and shared secret.
|
|
239
|
+
*/
|
|
240
|
+
async rotateInvite(): Promise<void> {
|
|
241
|
+
await this.init()
|
|
242
|
+
|
|
243
|
+
this.invite = Invite.createNew(this.devicePublicKey, this.devicePublicKey)
|
|
244
|
+
await this.saveInvite(this.invite)
|
|
245
|
+
|
|
246
|
+
const inviteEvent = this.invite.getEvent()
|
|
247
|
+
const signedInvite = finalizeEvent(inviteEvent, this.devicePrivateKey)
|
|
248
|
+
await this.nostrPublish(signedInvite)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Activate this device with a known owner.
|
|
253
|
+
* Use this when you know the device has been added (e.g., main device adding itself).
|
|
254
|
+
* Skips fetching from relay - just stores the owner pubkey.
|
|
255
|
+
*/
|
|
256
|
+
async activate(ownerPublicKey: string): Promise<void> {
|
|
257
|
+
this.ownerPubkeyFromActivation = ownerPublicKey
|
|
258
|
+
await this.storage.put(this.ownerPubkeyKey(), ownerPublicKey)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Wait for this device to be activated (added to an AppKeys).
|
|
263
|
+
* Returns the owner's public key once activated.
|
|
264
|
+
* For delegate devices that don't know the owner ahead of time.
|
|
265
|
+
*/
|
|
266
|
+
async waitForActivation(timeoutMs = 60000): Promise<string> {
|
|
267
|
+
if (this.ownerPubkeyFromActivation) {
|
|
268
|
+
return this.ownerPubkeyFromActivation
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return new Promise((resolve, reject) => {
|
|
272
|
+
const timeout = setTimeout(() => {
|
|
273
|
+
unsubscribe()
|
|
274
|
+
reject(new Error("Activation timeout"))
|
|
275
|
+
}, timeoutMs)
|
|
276
|
+
|
|
277
|
+
// Subscribe to all AppKeys events and look for our identityPubkey
|
|
278
|
+
const unsubscribe = this.nostrSubscribe(
|
|
279
|
+
{
|
|
280
|
+
kinds: [APP_KEYS_EVENT_KIND],
|
|
281
|
+
"#d": ["double-ratchet/app-keys"],
|
|
282
|
+
},
|
|
283
|
+
async (event) => {
|
|
284
|
+
try {
|
|
285
|
+
const appKeys = AppKeys.fromEvent(event)
|
|
286
|
+
const device = appKeys.getDevice(this.devicePublicKey)
|
|
287
|
+
|
|
288
|
+
// Check that our identity pubkey is in the list
|
|
289
|
+
if (device) {
|
|
290
|
+
clearTimeout(timeout)
|
|
291
|
+
unsubscribe()
|
|
292
|
+
this.ownerPubkeyFromActivation = event.pubkey
|
|
293
|
+
await this.storage.put(this.ownerPubkeyKey(), event.pubkey)
|
|
294
|
+
resolve(event.pubkey)
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
// Invalid AppKeys
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
this.subscriptions.push(unsubscribe)
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Check if this device has been revoked from the owner's AppKeys.
|
|
308
|
+
* @param options.timeoutMs - Timeout for each attempt (default 2000ms)
|
|
309
|
+
* @param options.retries - Number of retry attempts (default 2)
|
|
310
|
+
*/
|
|
311
|
+
async isRevoked(options: { timeoutMs?: number; retries?: number } = {}): Promise<boolean> {
|
|
312
|
+
const { timeoutMs = 2000, retries = 2 } = options
|
|
313
|
+
const ownerPubkey = this.getOwnerPublicKey()
|
|
314
|
+
if (!ownerPubkey) return false
|
|
315
|
+
|
|
316
|
+
// Retry loop to handle slow relays
|
|
317
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
318
|
+
const appKeys = await AppKeys.waitFor(ownerPubkey, this.nostrSubscribe, timeoutMs)
|
|
319
|
+
if (appKeys) {
|
|
320
|
+
const device = appKeys.getDevice(this.devicePublicKey)
|
|
321
|
+
// Device is revoked if not in list
|
|
322
|
+
return !device
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// No AppKeys found after all retries - assume revoked
|
|
327
|
+
return true
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
close(): void {
|
|
331
|
+
for (const unsubscribe of this.subscriptions) {
|
|
332
|
+
unsubscribe()
|
|
333
|
+
}
|
|
334
|
+
this.subscriptions = []
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Create a SessionManager for this device.
|
|
339
|
+
*/
|
|
340
|
+
createSessionManager(sessionStorage?: StorageAdapter): SessionManager {
|
|
341
|
+
if (!this.initialized) {
|
|
342
|
+
throw new Error("DelegateManager must be initialized before creating SessionManager")
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const ownerPublicKey = this.getOwnerPublicKey()
|
|
346
|
+
if (!ownerPublicKey) {
|
|
347
|
+
throw new Error("Owner public key required for SessionManager - device must be activated first")
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (!this.invite || !this.invite.inviterEphemeralPrivateKey) {
|
|
351
|
+
throw new Error("Invite with ephemeral keys required for SessionManager")
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const ephemeralKeypair = {
|
|
355
|
+
publicKey: this.invite.inviterEphemeralPublicKey,
|
|
356
|
+
privateKey: this.invite.inviterEphemeralPrivateKey,
|
|
357
|
+
}
|
|
358
|
+
const sharedSecret = this.invite.sharedSecret
|
|
359
|
+
|
|
360
|
+
return new SessionManager(
|
|
361
|
+
this.devicePublicKey,
|
|
362
|
+
this.devicePrivateKey,
|
|
363
|
+
this.devicePublicKey, // Use identityPubkey as deviceId
|
|
364
|
+
this.nostrSubscribe,
|
|
365
|
+
this.nostrPublish,
|
|
366
|
+
ownerPublicKey,
|
|
367
|
+
{ ephemeralKeypair, sharedSecret },
|
|
368
|
+
sessionStorage || this.storage,
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private ownerPubkeyKey(): string {
|
|
373
|
+
return `${this.versionPrefix}/device-manager/owner-pubkey`
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private inviteKey(): string {
|
|
377
|
+
return `${this.versionPrefix}/device-manager/invite`
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private async loadInvite(): Promise<Invite | null> {
|
|
381
|
+
const data = await this.storage.get<string>(this.inviteKey())
|
|
382
|
+
if (!data) return null
|
|
383
|
+
try {
|
|
384
|
+
return Invite.deserialize(data)
|
|
385
|
+
} catch {
|
|
386
|
+
return null
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private async saveInvite(invite: Invite): Promise<void> {
|
|
391
|
+
await this.storage.put(this.inviteKey(), invite.serialize())
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private identityPublicKeyKey(): string {
|
|
395
|
+
return `${this.versionPrefix}/device-manager/identity-public-key`
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private identityPrivateKeyKey(): string {
|
|
399
|
+
return `${this.versionPrefix}/device-manager/identity-private-key`
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Backwards compatibility aliases
|
|
404
|
+
export { AppKeysManager as ApplicationManager }
|
|
405
|
+
export type { AppKeysManagerOptions as ApplicationManagerOptions }
|
package/src/Invite.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
40
|
-
const
|
|
41
|
-
const sharedSecret = bytesToHex(generateSecretKey());
|
|
41
|
+
const ephemeralKeypair = generateEphemeralKeypair();
|
|
42
|
+
const sharedSecret = generateSharedSecret();
|
|
42
43
|
return new Invite(
|
|
43
|
-
|
|
44
|
+
ephemeralKeypair.publicKey,
|
|
44
45
|
sharedSecret,
|
|
45
46
|
inviter,
|
|
46
|
-
|
|
47
|
+
ephemeralKeypair.privateKey,
|
|
47
48
|
deviceId,
|
|
48
49
|
maxUses
|
|
49
50
|
);
|
|
@@ -197,8 +198,8 @@ export class Invite {
|
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
/**
|
|
200
|
-
* Creates
|
|
201
|
-
*
|
|
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.
|
|
202
203
|
*/
|
|
203
204
|
getDeletionEvent(): UnsignedEvent {
|
|
204
205
|
if (!this.deviceId) {
|
|
@@ -207,12 +208,11 @@ export class Invite {
|
|
|
207
208
|
return {
|
|
208
209
|
kind: INVITE_EVENT_KIND,
|
|
209
210
|
pubkey: this.inviter,
|
|
210
|
-
content: "",
|
|
211
|
+
content: "",
|
|
211
212
|
created_at: Math.floor(Date.now() / 1000),
|
|
212
213
|
tags: [
|
|
213
|
-
['
|
|
214
|
-
['
|
|
215
|
-
['d', 'double-ratchet/invites/' + this.deviceId], // same d tag
|
|
214
|
+
['d', 'double-ratchet/invites/' + this.deviceId],
|
|
215
|
+
['l', 'double-ratchet/invites'],
|
|
216
216
|
],
|
|
217
217
|
};
|
|
218
218
|
}
|
|
@@ -221,13 +221,13 @@ export class Invite {
|
|
|
221
221
|
* Called by the invitee. Accepts the invite and creates a new session with the inviter.
|
|
222
222
|
*
|
|
223
223
|
* @param nostrSubscribe - A function to subscribe to Nostr events
|
|
224
|
-
* @param inviteePublicKey - The invitee's public key
|
|
224
|
+
* @param inviteePublicKey - The invitee's identity public key (also serves as device ID)
|
|
225
225
|
* @param encryptor - The invitee's secret key or a signing/encrypt function
|
|
226
|
-
* @param
|
|
226
|
+
* @param ownerPublicKey - The invitee's owner/Nostr identity public key (optional for single-device users)
|
|
227
227
|
* @returns An object containing the new session and an event to be published
|
|
228
228
|
*
|
|
229
229
|
* 1. Inner event: No signature, content encrypted with DH(inviter, invitee).
|
|
230
|
-
* Purpose: Authenticate invitee. Contains invitee session key and
|
|
230
|
+
* Purpose: Authenticate invitee. Contains invitee session key and ownerPublicKey.
|
|
231
231
|
* 2. Envelope: No signature, content encrypted with DH(inviter, random key).
|
|
232
232
|
* Purpose: Contains inner event. Hides invitee from others who might have the shared Nostr key.
|
|
233
233
|
|
|
@@ -238,54 +238,41 @@ export class Invite {
|
|
|
238
238
|
nostrSubscribe: NostrSubscribe,
|
|
239
239
|
inviteePublicKey: string,
|
|
240
240
|
encryptor: Uint8Array | EncryptFunction,
|
|
241
|
-
|
|
241
|
+
ownerPublicKey?: string,
|
|
242
242
|
): Promise<{ session: Session, event: VerifiedEvent }> {
|
|
243
|
-
const
|
|
244
|
-
const inviteeSessionPublicKey = getPublicKey(inviteeSessionKey);
|
|
243
|
+
const inviteeSessionKeypair = generateEphemeralKeypair();
|
|
245
244
|
const inviterPublicKey = this.inviter || this.inviterEphemeralPublicKey;
|
|
246
245
|
|
|
247
|
-
const
|
|
248
|
-
|
|
246
|
+
const session = createSessionFromAccept({
|
|
247
|
+
nostrSubscribe,
|
|
248
|
+
theirPublicKey: this.inviterEphemeralPublicKey,
|
|
249
|
+
ourSessionPrivateKey: inviteeSessionKeypair.privateKey,
|
|
250
|
+
sharedSecret: this.sharedSecret,
|
|
251
|
+
isSender: true,
|
|
252
|
+
});
|
|
249
253
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const encrypt = typeof encryptor === 'function' ?
|
|
253
|
-
encryptor :
|
|
254
|
-
(plaintext: string, pubkey: string) => Promise.resolve(nip44.encrypt(plaintext, getConversationKey(encryptor, pubkey)));
|
|
254
|
+
const encrypt = typeof encryptor === 'function' ? encryptor : undefined;
|
|
255
|
+
const inviteePrivateKey = typeof encryptor === 'function' ? undefined : encryptor;
|
|
255
256
|
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
257
|
+
const encrypted = await encryptInviteResponse({
|
|
258
|
+
inviteeSessionPublicKey: inviteeSessionKeypair.publicKey,
|
|
259
|
+
inviteePublicKey,
|
|
260
|
+
inviteePrivateKey,
|
|
261
|
+
inviterPublicKey,
|
|
262
|
+
inviterEphemeralPublicKey: this.inviterEphemeralPublicKey,
|
|
263
|
+
sharedSecret: this.sharedSecret,
|
|
264
|
+
ownerPublicKey,
|
|
265
|
+
encrypt,
|
|
259
266
|
});
|
|
260
|
-
const dhEncrypted = await encrypt(payload, inviterPublicKey);
|
|
261
|
-
|
|
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
267
|
|
|
281
|
-
return { session, event: finalizeEvent(envelope,
|
|
268
|
+
return { session, event: finalizeEvent(encrypted.envelope, encrypted.randomSenderPrivateKey) };
|
|
282
269
|
}
|
|
283
270
|
|
|
284
|
-
listen(decryptor: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (_session: Session, _identity: string
|
|
271
|
+
listen(decryptor: Uint8Array | DecryptFunction, nostrSubscribe: NostrSubscribe, onSession: (_session: Session, _identity: string) => void): Unsubscribe {
|
|
285
272
|
if (!this.inviterEphemeralPrivateKey) {
|
|
286
273
|
throw new Error("Inviter session key is not available");
|
|
287
274
|
}
|
|
288
|
-
|
|
275
|
+
|
|
289
276
|
const filter = {
|
|
290
277
|
kinds: [INVITE_RESPONSE_KIND],
|
|
291
278
|
'#p': [this.inviterEphemeralPublicKey],
|
|
@@ -297,40 +284,33 @@ export class Invite {
|
|
|
297
284
|
return;
|
|
298
285
|
}
|
|
299
286
|
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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);
|
|
287
|
+
const decrypt = typeof decryptor === 'function' ? decryptor : undefined;
|
|
288
|
+
const inviterPrivateKey = typeof decryptor === 'function' ? undefined : decryptor;
|
|
289
|
+
|
|
290
|
+
const decrypted = await decryptInviteResponse({
|
|
291
|
+
envelopeContent: event.content,
|
|
292
|
+
envelopeSenderPubkey: event.pubkey,
|
|
293
|
+
inviterEphemeralPrivateKey: this.inviterEphemeralPrivateKey!,
|
|
294
|
+
inviterPrivateKey,
|
|
295
|
+
sharedSecret: this.sharedSecret,
|
|
296
|
+
decrypt,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
this.usedBy.push(decrypted.inviteeIdentity);
|
|
300
|
+
|
|
301
|
+
const session = createSessionFromAccept({
|
|
302
|
+
nostrSubscribe,
|
|
303
|
+
theirPublicKey: decrypted.inviteeSessionPublicKey,
|
|
304
|
+
ourSessionPrivateKey: this.inviterEphemeralPrivateKey!,
|
|
305
|
+
sharedSecret: this.sharedSecret,
|
|
306
|
+
isSender: false,
|
|
307
|
+
name: event.id,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// inviteeIdentity serves as both identity and device ID
|
|
311
|
+
onSession(session, decrypted.inviteeIdentity);
|
|
333
312
|
} catch {
|
|
313
|
+
// Failed to process invite response
|
|
334
314
|
}
|
|
335
315
|
});
|
|
336
316
|
}
|