nostr-double-ratchet 0.0.38 → 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 +5 -6
- 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 +15 -8
- package/dist/SessionManager.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/inviteUtils.d.ts +6 -6
- package/dist/inviteUtils.d.ts.map +1 -1
- package/dist/nostr-double-ratchet.es.js +2518 -2168
- package/dist/nostr-double-ratchet.umd.js +1 -1
- package/dist/types.d.ts +24 -5
- 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 +9 -8
- package/src/Session.ts +46 -3
- package/src/SessionManager.ts +341 -174
- package/src/index.ts +3 -3
- package/src/inviteUtils.ts +12 -11
- package/src/types.ts +28 -5
- package/src/utils.ts +42 -5
- package/LICENSE +0 -21
- package/dist/DeviceManager.d.ts +0 -127
- package/dist/DeviceManager.d.ts.map +0 -1
- package/dist/InviteList.d.ts +0 -43
- package/dist/InviteList.d.ts.map +0 -1
- package/dist/UserRecord.d.ts +0 -117
- package/dist/UserRecord.d.ts.map +0 -1
- package/src/DeviceManager.ts +0 -565
- package/src/InviteList.ts +0 -333
- 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
|
@@ -200,7 +200,6 @@ export class Invite {
|
|
|
200
200
|
/**
|
|
201
201
|
* Creates a tombstone event that replaces the invite, signaling device revocation.
|
|
202
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.
|
|
204
203
|
*/
|
|
205
204
|
getDeletionEvent(): UnsignedEvent {
|
|
206
205
|
if (!this.deviceId) {
|
|
@@ -222,13 +221,13 @@ export class Invite {
|
|
|
222
221
|
* Called by the invitee. Accepts the invite and creates a new session with the inviter.
|
|
223
222
|
*
|
|
224
223
|
* @param nostrSubscribe - A function to subscribe to Nostr events
|
|
225
|
-
* @param inviteePublicKey - The invitee's public key
|
|
224
|
+
* @param inviteePublicKey - The invitee's identity public key (also serves as device ID)
|
|
226
225
|
* @param encryptor - The invitee's secret key or a signing/encrypt function
|
|
227
|
-
* @param
|
|
226
|
+
* @param ownerPublicKey - The invitee's owner/Nostr identity public key (optional for single-device users)
|
|
228
227
|
* @returns An object containing the new session and an event to be published
|
|
229
228
|
*
|
|
230
229
|
* 1. Inner event: No signature, content encrypted with DH(inviter, invitee).
|
|
231
|
-
* Purpose: Authenticate invitee. Contains invitee session key and
|
|
230
|
+
* Purpose: Authenticate invitee. Contains invitee session key and ownerPublicKey.
|
|
232
231
|
* 2. Envelope: No signature, content encrypted with DH(inviter, random key).
|
|
233
232
|
* Purpose: Contains inner event. Hides invitee from others who might have the shared Nostr key.
|
|
234
233
|
|
|
@@ -239,7 +238,7 @@ export class Invite {
|
|
|
239
238
|
nostrSubscribe: NostrSubscribe,
|
|
240
239
|
inviteePublicKey: string,
|
|
241
240
|
encryptor: Uint8Array | EncryptFunction,
|
|
242
|
-
|
|
241
|
+
ownerPublicKey?: string,
|
|
243
242
|
): Promise<{ session: Session, event: VerifiedEvent }> {
|
|
244
243
|
const inviteeSessionKeypair = generateEphemeralKeypair();
|
|
245
244
|
const inviterPublicKey = this.inviter || this.inviterEphemeralPublicKey;
|
|
@@ -262,14 +261,14 @@ export class Invite {
|
|
|
262
261
|
inviterPublicKey,
|
|
263
262
|
inviterEphemeralPublicKey: this.inviterEphemeralPublicKey,
|
|
264
263
|
sharedSecret: this.sharedSecret,
|
|
265
|
-
|
|
264
|
+
ownerPublicKey,
|
|
266
265
|
encrypt,
|
|
267
266
|
});
|
|
268
267
|
|
|
269
268
|
return { session, event: finalizeEvent(encrypted.envelope, encrypted.randomSenderPrivateKey) };
|
|
270
269
|
}
|
|
271
270
|
|
|
272
|
-
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 {
|
|
273
272
|
if (!this.inviterEphemeralPrivateKey) {
|
|
274
273
|
throw new Error("Inviter session key is not available");
|
|
275
274
|
}
|
|
@@ -308,8 +307,10 @@ export class Invite {
|
|
|
308
307
|
name: event.id,
|
|
309
308
|
});
|
|
310
309
|
|
|
311
|
-
|
|
310
|
+
// inviteeIdentity serves as both identity and device ID
|
|
311
|
+
onSession(session, decrypted.inviteeIdentity);
|
|
312
312
|
} catch {
|
|
313
|
+
// Failed to process invite response
|
|
313
314
|
}
|
|
314
315
|
});
|
|
315
316
|
}
|
package/src/Session.ts
CHANGED
|
@@ -9,8 +9,11 @@ import {
|
|
|
9
9
|
MESSAGE_EVENT_KIND,
|
|
10
10
|
Rumor,
|
|
11
11
|
CHAT_MESSAGE_KIND,
|
|
12
|
+
REACTION_KIND,
|
|
13
|
+
RECEIPT_KIND,
|
|
14
|
+
TYPING_KIND,
|
|
12
15
|
} from "./types";
|
|
13
|
-
import { kdf, deepCopyState } from "./utils";
|
|
16
|
+
import { kdf, deepCopyState, createReactionPayload } from "./utils";
|
|
14
17
|
|
|
15
18
|
const MAX_SKIP = 1000;
|
|
16
19
|
|
|
@@ -93,7 +96,7 @@ export class Session {
|
|
|
93
96
|
previousSendingChainMessageCount: 0,
|
|
94
97
|
skippedKeys: {},
|
|
95
98
|
};
|
|
96
|
-
|
|
99
|
+
|
|
97
100
|
const session = new Session(nostrSubscribe, state);
|
|
98
101
|
if (name) session.name = name;
|
|
99
102
|
return session;
|
|
@@ -113,6 +116,46 @@ export class Session {
|
|
|
113
116
|
});
|
|
114
117
|
}
|
|
115
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Sends a reaction to a message through the encrypted session.
|
|
121
|
+
* @param messageId The ID of the message being reacted to
|
|
122
|
+
* @param emoji The emoji or reaction content (e.g., "👍", "❤️", "+1")
|
|
123
|
+
* @returns A verified Nostr event containing the encrypted reaction. You need to publish this event to the Nostr network.
|
|
124
|
+
* @throws Error if we are not the initiator and trying to send the first message
|
|
125
|
+
*/
|
|
126
|
+
sendReaction(messageId: string, emoji: string): {event: VerifiedEvent, innerEvent: Rumor} {
|
|
127
|
+
return this.sendEvent({
|
|
128
|
+
content: createReactionPayload(messageId, emoji),
|
|
129
|
+
kind: REACTION_KIND,
|
|
130
|
+
tags: [["e", messageId]]
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Sends a typing indicator through the encrypted session.
|
|
136
|
+
* @returns A verified Nostr event containing the encrypted typing indicator. You need to publish this event to the Nostr network.
|
|
137
|
+
*/
|
|
138
|
+
sendTyping(): {event: VerifiedEvent, innerEvent: Rumor} {
|
|
139
|
+
return this.sendEvent({
|
|
140
|
+
content: 'typing',
|
|
141
|
+
kind: TYPING_KIND,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Sends a delivery/read receipt through the encrypted session.
|
|
147
|
+
* @param receiptType Either "delivered" or "seen"
|
|
148
|
+
* @param messageIds The IDs of the messages being acknowledged
|
|
149
|
+
* @returns A verified Nostr event containing the encrypted receipt. You need to publish this event to the Nostr network.
|
|
150
|
+
*/
|
|
151
|
+
sendReceipt(receiptType: 'delivered' | 'seen', messageIds: string[]): {event: VerifiedEvent, innerEvent: Rumor} {
|
|
152
|
+
return this.sendEvent({
|
|
153
|
+
content: receiptType,
|
|
154
|
+
kind: RECEIPT_KIND,
|
|
155
|
+
tags: messageIds.map(id => ["e", id]),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
116
159
|
/**
|
|
117
160
|
* Send a partial Nostr event through the encrypted session.
|
|
118
161
|
* In addition to chat messages, it could be files, webrtc negotiation or many other types of messages.
|
|
@@ -401,7 +444,7 @@ export class Session {
|
|
|
401
444
|
this.skippedSubscription = this.nostrSubscribe(
|
|
402
445
|
{authors: skippedAuthors, kinds: [MESSAGE_EVENT_KIND]},
|
|
403
446
|
(e) => this.handleNostrEvent(e)
|
|
404
|
-
);
|
|
447
|
+
);
|
|
405
448
|
}
|
|
406
449
|
}
|
|
407
450
|
}
|