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/dist/DeviceManager.d.ts +127 -0
- package/dist/DeviceManager.d.ts.map +1 -0
- package/dist/Invite.d.ts +3 -2
- package/dist/Invite.d.ts.map +1 -1
- package/dist/InviteList.d.ts +43 -0
- package/dist/InviteList.d.ts.map +1 -0
- package/dist/SessionManager.d.ts +35 -18
- 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 +2374 -1782
- package/dist/nostr-double-ratchet.umd.js +1 -1
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/DeviceManager.ts +565 -0
- package/src/Invite.ts +63 -84
- package/src/InviteList.ts +333 -0
- package/src/Session.ts +1 -1
- package/src/SessionManager.ts +240 -229
- package/src/StorageAdapter.ts +5 -6
- package/src/index.ts +3 -0
- package/src/inviteUtils.ts +270 -0
- package/src/types.ts +13 -1
- package/src/utils.ts +3 -3
package/src/SessionManager.ts
CHANGED
|
@@ -1,26 +1,38 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
IdentityKey,
|
|
3
3
|
NostrSubscribe,
|
|
4
4
|
NostrPublish,
|
|
5
5
|
Rumor,
|
|
6
6
|
Unsubscribe,
|
|
7
|
-
|
|
7
|
+
INVITE_LIST_EVENT_KIND,
|
|
8
8
|
CHAT_MESSAGE_KIND,
|
|
9
9
|
} from "./types"
|
|
10
10
|
import { StorageAdapter, InMemoryStorageAdapter } from "./StorageAdapter"
|
|
11
|
-
import {
|
|
11
|
+
import { InviteList } from "./InviteList"
|
|
12
12
|
import { Session } from "./Session"
|
|
13
13
|
import { serializeSessionState, deserializeSessionState } from "./utils"
|
|
14
|
-
import {
|
|
14
|
+
import { decryptInviteResponse, createSessionFromAccept } from "./inviteUtils"
|
|
15
|
+
import { getEventHash } from "nostr-tools"
|
|
15
16
|
|
|
16
17
|
export type OnEventCallback = (event: Rumor, from: string) => void
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Credentials for the invite handshake - used to listen for and decrypt invite responses
|
|
21
|
+
*/
|
|
22
|
+
export interface InviteCredentials {
|
|
23
|
+
ephemeralKeypair: { publicKey: string; privateKey: Uint8Array }
|
|
24
|
+
sharedSecret: string
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
interface DeviceRecord {
|
|
19
28
|
deviceId: string
|
|
20
29
|
activeSession?: Session
|
|
21
30
|
inactiveSessions: Session[]
|
|
22
31
|
createdAt: number
|
|
23
32
|
staleAt?: number
|
|
33
|
+
// Set to true when we've processed an invite response from this device
|
|
34
|
+
// This survives restarts and prevents duplicate RESPONDER session creation
|
|
35
|
+
hasResponderSession?: boolean
|
|
24
36
|
}
|
|
25
37
|
|
|
26
38
|
interface UserRecord {
|
|
@@ -36,6 +48,7 @@ interface StoredDeviceRecord {
|
|
|
36
48
|
inactiveSessions: StoredSessionEntry[]
|
|
37
49
|
createdAt: number
|
|
38
50
|
staleAt?: number
|
|
51
|
+
hasResponderSession?: boolean
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
interface StoredUserRecord {
|
|
@@ -53,20 +66,24 @@ export class SessionManager {
|
|
|
53
66
|
private storage: StorageAdapter
|
|
54
67
|
private nostrSubscribe: NostrSubscribe
|
|
55
68
|
private nostrPublish: NostrPublish
|
|
56
|
-
private
|
|
69
|
+
private identityKey: IdentityKey
|
|
57
70
|
private ourPublicKey: string
|
|
71
|
+
// Owner's public key - used for grouping devices together (all devices are delegates)
|
|
72
|
+
private ownerPublicKey: string
|
|
73
|
+
|
|
74
|
+
// Credentials for invite handshake
|
|
75
|
+
private inviteKeys: InviteCredentials
|
|
58
76
|
|
|
59
77
|
// Data
|
|
60
78
|
private userRecords: Map<string, UserRecord> = new Map()
|
|
61
79
|
private messageHistory: Map<string, Rumor[]> = new Map()
|
|
62
|
-
|
|
80
|
+
// Map delegate device pubkeys to their owner's pubkey
|
|
81
|
+
private delegateToOwner: Map<string, string> = new Map()
|
|
63
82
|
|
|
64
83
|
// Subscriptions
|
|
65
|
-
private
|
|
66
|
-
private ourDeviceIntiveTombstoneSubscription: Unsubscribe | null = null
|
|
84
|
+
private ourInviteResponseSubscription: Unsubscribe | null = null
|
|
67
85
|
private inviteSubscriptions: Map<string, Unsubscribe> = new Map()
|
|
68
86
|
private sessionSubscriptions: Map<string, Unsubscribe> = new Map()
|
|
69
|
-
private inviteTombstoneSubscriptions: Map<string, Unsubscribe> = new Map()
|
|
70
87
|
|
|
71
88
|
// Callbacks
|
|
72
89
|
private internalSubscriptions: Set<OnEventCallback> = new Set()
|
|
@@ -76,18 +93,22 @@ export class SessionManager {
|
|
|
76
93
|
|
|
77
94
|
constructor(
|
|
78
95
|
ourPublicKey: string,
|
|
79
|
-
|
|
96
|
+
identityKey: IdentityKey,
|
|
80
97
|
deviceId: string,
|
|
81
98
|
nostrSubscribe: NostrSubscribe,
|
|
82
99
|
nostrPublish: NostrPublish,
|
|
83
|
-
|
|
100
|
+
ownerPublicKey: string,
|
|
101
|
+
inviteKeys: InviteCredentials,
|
|
102
|
+
storage?: StorageAdapter,
|
|
84
103
|
) {
|
|
85
104
|
this.userRecords = new Map()
|
|
86
105
|
this.nostrSubscribe = nostrSubscribe
|
|
87
106
|
this.nostrPublish = nostrPublish
|
|
88
107
|
this.ourPublicKey = ourPublicKey
|
|
89
|
-
this.
|
|
108
|
+
this.identityKey = identityKey
|
|
90
109
|
this.deviceId = deviceId
|
|
110
|
+
this.ownerPublicKey = ownerPublicKey
|
|
111
|
+
this.inviteKeys = inviteKeys
|
|
91
112
|
this.storage = storage || new InMemoryStorageAdapter()
|
|
92
113
|
this.versionPrefix = `v${this.storageVersion}`
|
|
93
114
|
}
|
|
@@ -104,55 +125,100 @@ export class SessionManager {
|
|
|
104
125
|
console.error("Failed to load user records:", error)
|
|
105
126
|
})
|
|
106
127
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
128
|
+
// Add our own device to user record to prevent accepting our own invite
|
|
129
|
+
// Use ownerPublicKey so delegates are added to the owner's record
|
|
130
|
+
const ourUserRecord = this.getOrCreateUserRecord(this.ownerPublicKey)
|
|
131
|
+
this.upsertDeviceRecord(ourUserRecord, this.deviceId)
|
|
132
|
+
|
|
133
|
+
// Start invite response listener BEFORE setting up users
|
|
134
|
+
// This ensures we're listening when other devices respond to our invites
|
|
135
|
+
this.startInviteResponseListener()
|
|
136
|
+
// Setup sessions with our own other devices
|
|
137
|
+
// Use ownerPublicKey to find sibling devices (important for delegates)
|
|
138
|
+
this.setupUser(this.ownerPublicKey)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Start listening for invite responses on our ephemeral key.
|
|
143
|
+
* This is used by devices to receive session establishment responses.
|
|
144
|
+
*/
|
|
145
|
+
private startInviteResponseListener(): void {
|
|
146
|
+
const { publicKey: ephemeralPubkey, privateKey: ephemeralPrivkey } = this.inviteKeys.ephemeralKeypair
|
|
147
|
+
const sharedSecret = this.inviteKeys.sharedSecret
|
|
148
|
+
|
|
149
|
+
// Subscribe to invite responses tagged to our ephemeral key
|
|
150
|
+
this.ourInviteResponseSubscription = this.nostrSubscribe(
|
|
151
|
+
{
|
|
152
|
+
kinds: [1059], // INVITE_RESPONSE_KIND
|
|
153
|
+
"#p": [ephemeralPubkey],
|
|
154
|
+
},
|
|
155
|
+
async (event) => {
|
|
111
156
|
try {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
157
|
+
const decrypted = await decryptInviteResponse({
|
|
158
|
+
envelopeContent: event.content,
|
|
159
|
+
envelopeSenderPubkey: event.pubkey,
|
|
160
|
+
inviterEphemeralPrivateKey: ephemeralPrivkey,
|
|
161
|
+
inviterPrivateKey: this.identityKey instanceof Uint8Array ? this.identityKey : undefined,
|
|
162
|
+
sharedSecret,
|
|
163
|
+
decrypt: this.identityKey instanceof Uint8Array ? undefined : this.identityKey.decrypt,
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// Skip our own responses - this happens when we publish an invite response
|
|
167
|
+
// and our own listener receives it back from relays
|
|
168
|
+
if (decrypted.deviceId === this.deviceId) {
|
|
169
|
+
return
|
|
170
|
+
}
|
|
117
171
|
|
|
118
|
-
|
|
119
|
-
|
|
172
|
+
// Resolve delegate pubkey to owner for correct UserRecord attribution
|
|
173
|
+
const ownerPubkey = this.resolveToOwner(decrypted.inviteeIdentity)
|
|
174
|
+
const userRecord = this.getOrCreateUserRecord(ownerPubkey)
|
|
175
|
+
const deviceRecord = this.upsertDeviceRecord(userRecord, decrypted.deviceId || "default")
|
|
120
176
|
|
|
121
|
-
|
|
177
|
+
// Check for duplicate/stale responses using the persisted flag
|
|
178
|
+
// This flag survives restarts and prevents creating duplicate RESPONDER sessions
|
|
179
|
+
if (deviceRecord.hasResponderSession) {
|
|
180
|
+
return
|
|
181
|
+
}
|
|
122
182
|
|
|
123
|
-
|
|
183
|
+
// Also check session state as a fallback (for existing sessions before the flag was added)
|
|
184
|
+
const responseSessionKey = decrypted.inviteeSessionPublicKey
|
|
185
|
+
const existingSession = deviceRecord.activeSession
|
|
186
|
+
const existingInactive = deviceRecord.inactiveSessions || []
|
|
187
|
+
const allSessions = existingSession ? [existingSession, ...existingInactive] : existingInactive
|
|
188
|
+
|
|
189
|
+
// Check if any existing session can already receive from this device:
|
|
190
|
+
// - Has receivingChainKey set (RESPONDER session has received messages)
|
|
191
|
+
// - Has the same theirNextNostrPublicKey (same session, duplicate response)
|
|
192
|
+
const canAlreadyReceive = allSessions.some(s =>
|
|
193
|
+
s.state?.receivingChainKey !== undefined ||
|
|
194
|
+
s.state?.theirNextNostrPublicKey === responseSessionKey ||
|
|
195
|
+
s.state?.theirCurrentNostrPublicKey === responseSessionKey
|
|
196
|
+
)
|
|
197
|
+
if (canAlreadyReceive) {
|
|
198
|
+
return
|
|
199
|
+
}
|
|
124
200
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
201
|
+
const session = createSessionFromAccept({
|
|
202
|
+
nostrSubscribe: this.nostrSubscribe,
|
|
203
|
+
theirPublicKey: decrypted.inviteeSessionPublicKey,
|
|
204
|
+
ourSessionPrivateKey: ephemeralPrivkey,
|
|
205
|
+
sharedSecret,
|
|
206
|
+
isSender: false,
|
|
207
|
+
name: event.id,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// Mark that we've processed a responder session for this device
|
|
211
|
+
// This flag is persisted and survives restarts
|
|
212
|
+
deviceRecord.hasResponderSession = true
|
|
213
|
+
|
|
214
|
+
this.attachSessionSubscription(ownerPubkey, deviceRecord, session, true)
|
|
215
|
+
// Persist the flag
|
|
216
|
+
this.storeUserRecord(ownerPubkey).catch(console.error)
|
|
217
|
+
} catch {
|
|
218
|
+
// Invalid response, ignore
|
|
135
219
|
}
|
|
136
|
-
|
|
137
|
-
await this.storage.put(acceptanceKey, "1")
|
|
138
|
-
|
|
139
|
-
const userRecord = this.getOrCreateUserRecord(inviteePubkey)
|
|
140
|
-
const deviceRecord = this.upsertDeviceRecord(userRecord, deviceId)
|
|
141
|
-
|
|
142
|
-
this.attachSessionSubscription(inviteePubkey, deviceRecord, session, true)
|
|
143
220
|
}
|
|
144
221
|
)
|
|
145
|
-
|
|
146
|
-
if (!this.ourDeviceIntiveTombstoneSubscription) {
|
|
147
|
-
this.ourDeviceIntiveTombstoneSubscription = this.createInviteTombstoneSubscription(
|
|
148
|
-
this.ourPublicKey
|
|
149
|
-
)
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const inviteNostrEvent = invite.getEvent()
|
|
153
|
-
this.nostrPublish(inviteNostrEvent).catch((error) => {
|
|
154
|
-
console.error("Failed to publish our device invite:", error)
|
|
155
|
-
})
|
|
156
222
|
}
|
|
157
223
|
|
|
158
224
|
// -------------------
|
|
@@ -185,56 +251,9 @@ export class SessionManager {
|
|
|
185
251
|
return deviceRecord
|
|
186
252
|
}
|
|
187
253
|
|
|
188
|
-
private createInviteTombstoneSubscription(authorPublicKey: string): Unsubscribe {
|
|
189
|
-
return this.nostrSubscribe(
|
|
190
|
-
{
|
|
191
|
-
kinds: [INVITE_EVENT_KIND],
|
|
192
|
-
authors: [authorPublicKey],
|
|
193
|
-
"#l": ["double-ratchet/invites"],
|
|
194
|
-
},
|
|
195
|
-
(event: VerifiedEvent) => {
|
|
196
|
-
try {
|
|
197
|
-
const isTombstone = !event.tags?.some(
|
|
198
|
-
([key]) => key === "ephemeralKey" || key === "sharedSecret"
|
|
199
|
-
)
|
|
200
|
-
if (isTombstone) {
|
|
201
|
-
const deviceIdTag = event.tags.find(
|
|
202
|
-
([key, value]) => key === "d" && value.startsWith("double-ratchet/invites/")
|
|
203
|
-
)
|
|
204
|
-
const [, deviceIdTagValue] = deviceIdTag || []
|
|
205
|
-
const deviceId = deviceIdTagValue.split("/").pop()
|
|
206
|
-
if (!deviceId) return
|
|
207
|
-
|
|
208
|
-
this.cleanupDevice(authorPublicKey, deviceId)
|
|
209
|
-
}
|
|
210
|
-
} catch (error) {
|
|
211
|
-
console.error("Failed to handle device tombstone:", error)
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
)
|
|
215
|
-
}
|
|
216
|
-
|
|
217
254
|
private sessionKey(userPubkey: string, deviceId: string, sessionName: string) {
|
|
218
255
|
return `${this.sessionKeyPrefix(userPubkey)}${deviceId}/${sessionName}`
|
|
219
256
|
}
|
|
220
|
-
private inviteKey(userPubkey: string) {
|
|
221
|
-
return this.userInviteKey(userPubkey)
|
|
222
|
-
}
|
|
223
|
-
private inviteAcceptKey(nostrEventId: string, userPubkey: string, deviceId: string) {
|
|
224
|
-
return `${this.inviteAcceptKeyPrefix(userPubkey)}${deviceId}/${nostrEventId}`
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
private deviceInviteKey(deviceId: string) {
|
|
228
|
-
return `${this.versionPrefix}/device-invite/${deviceId}`
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
private userInviteKey(userPubkey: string) {
|
|
232
|
-
return `${this.versionPrefix}/invite/${userPubkey}`
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
private inviteAcceptKeyPrefix(userPublicKey: string) {
|
|
236
|
-
return `${this.versionPrefix}/invite-accept/${userPublicKey}/`
|
|
237
|
-
}
|
|
238
257
|
|
|
239
258
|
private sessionKeyPrefix(userPubkey: string) {
|
|
240
259
|
return `${this.versionPrefix}/session/${userPubkey}/`
|
|
@@ -251,6 +270,49 @@ export class SessionManager {
|
|
|
251
270
|
return `storage-version`
|
|
252
271
|
}
|
|
253
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Resolve a pubkey to its owner if it's a known delegate device.
|
|
275
|
+
* Returns the input pubkey if not a known delegate.
|
|
276
|
+
*/
|
|
277
|
+
private resolveToOwner(pubkey: string): string {
|
|
278
|
+
return this.delegateToOwner.get(pubkey) || pubkey
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Update the delegate-to-owner mapping from an InviteList.
|
|
283
|
+
* Extracts delegate device pubkeys and maps them to the owner.
|
|
284
|
+
*/
|
|
285
|
+
private updateDelegateMapping(ownerPubkey: string, inviteList: InviteList): void {
|
|
286
|
+
for (const device of inviteList.getAllDevices()) {
|
|
287
|
+
if (device.identityPubkey) {
|
|
288
|
+
this.delegateToOwner.set(device.identityPubkey, ownerPubkey)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private subscribeToUserInviteList(
|
|
294
|
+
pubkey: string,
|
|
295
|
+
onInviteList: (list: InviteList) => void
|
|
296
|
+
): Unsubscribe {
|
|
297
|
+
return this.nostrSubscribe(
|
|
298
|
+
{
|
|
299
|
+
kinds: [INVITE_LIST_EVENT_KIND],
|
|
300
|
+
authors: [pubkey],
|
|
301
|
+
"#d": ["double-ratchet/invite-list"],
|
|
302
|
+
},
|
|
303
|
+
(event) => {
|
|
304
|
+
try {
|
|
305
|
+
const list = InviteList.fromEvent(event)
|
|
306
|
+
// Update delegate mapping whenever we receive an InviteList
|
|
307
|
+
this.updateDelegateMapping(pubkey, list)
|
|
308
|
+
onInviteList(list)
|
|
309
|
+
} catch {
|
|
310
|
+
// Invalid event, ignore
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
|
|
254
316
|
private attachSessionSubscription(
|
|
255
317
|
userPubkey: string,
|
|
256
318
|
deviceRecord: DeviceRecord,
|
|
@@ -258,10 +320,14 @@ export class SessionManager {
|
|
|
258
320
|
// Set to true if only handshake -> not yet sendable -> will be promoted on message
|
|
259
321
|
inactive: boolean = false
|
|
260
322
|
): void {
|
|
261
|
-
if (deviceRecord.staleAt !== undefined)
|
|
323
|
+
if (deviceRecord.staleAt !== undefined) {
|
|
324
|
+
return
|
|
325
|
+
}
|
|
262
326
|
|
|
263
327
|
const key = this.sessionKey(userPubkey, deviceRecord.deviceId, session.name)
|
|
264
|
-
if (this.sessionSubscriptions.has(key))
|
|
328
|
+
if (this.sessionSubscriptions.has(key)) {
|
|
329
|
+
return
|
|
330
|
+
}
|
|
265
331
|
|
|
266
332
|
const dr = deviceRecord
|
|
267
333
|
const rotateSession = (nextSession: Session) => {
|
|
@@ -307,62 +373,61 @@ export class SessionManager {
|
|
|
307
373
|
this.sessionSubscriptions.set(key, unsub)
|
|
308
374
|
}
|
|
309
375
|
|
|
310
|
-
private
|
|
376
|
+
private attachInviteListSubscription(
|
|
311
377
|
userPubkey: string,
|
|
312
|
-
|
|
378
|
+
onInviteList?: (inviteList: InviteList) => void | Promise<void>
|
|
313
379
|
): void {
|
|
314
|
-
const key =
|
|
380
|
+
const key = `invitelist:${userPubkey}`
|
|
315
381
|
if (this.inviteSubscriptions.has(key)) return
|
|
316
382
|
|
|
317
|
-
const unsubscribe =
|
|
383
|
+
const unsubscribe = this.subscribeToUserInviteList(
|
|
318
384
|
userPubkey,
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (!invite.deviceId) return
|
|
322
|
-
if (onInvite) await onInvite(invite)
|
|
385
|
+
async (inviteList) => {
|
|
386
|
+
if (onInviteList) await onInviteList(inviteList)
|
|
323
387
|
}
|
|
324
388
|
)
|
|
325
389
|
|
|
326
390
|
this.inviteSubscriptions.set(key, unsubscribe)
|
|
327
391
|
}
|
|
328
392
|
|
|
329
|
-
private attachInviteTombstoneSubscription(userPubkey: string): void {
|
|
330
|
-
if (this.inviteTombstoneSubscriptions.has(userPubkey)) {
|
|
331
|
-
return
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
const unsubscribe = this.createInviteTombstoneSubscription(userPubkey)
|
|
335
|
-
this.inviteTombstoneSubscriptions.set(userPubkey, unsubscribe)
|
|
336
|
-
}
|
|
337
|
-
|
|
338
393
|
setupUser(userPubkey: string) {
|
|
339
394
|
const userRecord = this.getOrCreateUserRecord(userPubkey)
|
|
340
395
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const
|
|
396
|
+
const acceptInviteFromDevice = async (
|
|
397
|
+
inviteList: InviteList,
|
|
398
|
+
deviceId: string
|
|
399
|
+
) => {
|
|
400
|
+
// Add device record IMMEDIATELY to prevent duplicate acceptance from race conditions
|
|
401
|
+
// (InviteList callback can fire multiple times before async accept completes)
|
|
402
|
+
const deviceRecord = this.upsertDeviceRecord(userRecord, deviceId)
|
|
403
|
+
|
|
404
|
+
const encryptor = this.identityKey instanceof Uint8Array ? this.identityKey : this.identityKey.encrypt
|
|
405
|
+
const { session, event } = await inviteList.accept(
|
|
406
|
+
deviceId,
|
|
348
407
|
this.nostrSubscribe,
|
|
349
408
|
this.ourPublicKey,
|
|
350
|
-
|
|
409
|
+
encryptor,
|
|
351
410
|
this.deviceId
|
|
352
411
|
)
|
|
353
412
|
return this.nostrPublish(event)
|
|
354
|
-
.then(() => this.
|
|
355
|
-
.then((dr) => this.attachSessionSubscription(userPubkey, dr, session))
|
|
413
|
+
.then(() => this.attachSessionSubscription(userPubkey, deviceRecord, session))
|
|
356
414
|
.then(() => this.sendMessageHistory(userPubkey, deviceId))
|
|
357
415
|
.catch(console.error)
|
|
358
416
|
}
|
|
359
417
|
|
|
360
|
-
this.
|
|
361
|
-
const
|
|
362
|
-
if (!deviceId) return
|
|
418
|
+
this.attachInviteListSubscription(userPubkey, async (inviteList) => {
|
|
419
|
+
const devices = inviteList.getAllDevices()
|
|
363
420
|
|
|
364
|
-
|
|
365
|
-
|
|
421
|
+
// Handle removed devices (source of truth for revocation)
|
|
422
|
+
for (const deviceId of inviteList.getRemovedDeviceIds()) {
|
|
423
|
+
await this.cleanupDevice(userPubkey, deviceId)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Accept invites from new devices
|
|
427
|
+
for (const device of devices) {
|
|
428
|
+
if (!userRecord.devices.has(device.deviceId)) {
|
|
429
|
+
await acceptInviteFromDevice(inviteList, device.deviceId)
|
|
430
|
+
}
|
|
366
431
|
}
|
|
367
432
|
})
|
|
368
433
|
}
|
|
@@ -379,10 +444,6 @@ export class SessionManager {
|
|
|
379
444
|
return this.deviceId
|
|
380
445
|
}
|
|
381
446
|
|
|
382
|
-
getDeviceInviteEphemeralKey(): string | null {
|
|
383
|
-
return this.currentDeviceInvite?.inviterEphemeralPublicKey || null
|
|
384
|
-
}
|
|
385
|
-
|
|
386
447
|
getUserRecords(): Map<string, UserRecord> {
|
|
387
448
|
return this.userRecords
|
|
388
449
|
}
|
|
@@ -396,12 +457,7 @@ export class SessionManager {
|
|
|
396
457
|
unsubscribe()
|
|
397
458
|
}
|
|
398
459
|
|
|
399
|
-
|
|
400
|
-
unsubscribe()
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
this.ourDeviceInviteSubscription?.()
|
|
404
|
-
this.ourDeviceIntiveTombstoneSubscription?.()
|
|
460
|
+
this.ourInviteResponseSubscription?.()
|
|
405
461
|
}
|
|
406
462
|
|
|
407
463
|
deactivateCurrentSessions(publicKey: string) {
|
|
@@ -439,23 +495,16 @@ export class SessionManager {
|
|
|
439
495
|
this.userRecords.delete(userPubkey)
|
|
440
496
|
}
|
|
441
497
|
|
|
442
|
-
const
|
|
443
|
-
const
|
|
444
|
-
if (
|
|
445
|
-
|
|
446
|
-
this.inviteSubscriptions.delete(
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
const tombstoneUnsub = this.inviteTombstoneSubscriptions.get(userPubkey)
|
|
450
|
-
if (tombstoneUnsub) {
|
|
451
|
-
tombstoneUnsub()
|
|
452
|
-
this.inviteTombstoneSubscriptions.delete(userPubkey)
|
|
498
|
+
const inviteListKey = `invitelist:${userPubkey}`
|
|
499
|
+
const inviteListUnsub = this.inviteSubscriptions.get(inviteListKey)
|
|
500
|
+
if (inviteListUnsub) {
|
|
501
|
+
inviteListUnsub()
|
|
502
|
+
this.inviteSubscriptions.delete(inviteListKey)
|
|
453
503
|
}
|
|
454
504
|
|
|
455
505
|
this.messageHistory.delete(userPubkey)
|
|
456
506
|
|
|
457
507
|
await Promise.allSettled([
|
|
458
|
-
this.storage.del(this.inviteKey(userPubkey)),
|
|
459
508
|
this.deleteUserSessionsFromStorage(userPubkey),
|
|
460
509
|
this.storage.del(this.userRecordKey(userPubkey)),
|
|
461
510
|
])
|
|
@@ -514,34 +563,54 @@ export class SessionManager {
|
|
|
514
563
|
|
|
515
564
|
// Add to message history queue (will be sent when session is established)
|
|
516
565
|
const completeEvent = event as Rumor
|
|
517
|
-
|
|
566
|
+
// Use ownerPublicKey for history targets so delegates share history with owner
|
|
567
|
+
const historyTargets = new Set([recipientIdentityKey, this.ownerPublicKey])
|
|
518
568
|
for (const key of historyTargets) {
|
|
519
569
|
const existing = this.messageHistory.get(key) || []
|
|
520
570
|
this.messageHistory.set(key, [...existing, completeEvent])
|
|
521
571
|
}
|
|
522
572
|
|
|
523
573
|
const userRecord = this.getOrCreateUserRecord(recipientIdentityKey)
|
|
524
|
-
|
|
574
|
+
// Use ownerPublicKey to find sibling devices (important for delegates)
|
|
575
|
+
const ourUserRecord = this.getOrCreateUserRecord(this.ownerPublicKey)
|
|
525
576
|
|
|
526
577
|
this.setupUser(recipientIdentityKey)
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
578
|
+
// Use ownerPublicKey to setup sessions with sibling devices
|
|
579
|
+
this.setupUser(this.ownerPublicKey)
|
|
580
|
+
|
|
581
|
+
const recipientDevices = Array.from(userRecord.devices.values()).filter(d => d.staleAt === undefined)
|
|
582
|
+
const ownDevices = Array.from(ourUserRecord.devices.values()).filter(d => d.staleAt === undefined)
|
|
583
|
+
|
|
584
|
+
// Merge and deduplicate by deviceId, excluding our own sending device
|
|
585
|
+
// This fixes the self-message bug where sending to yourself would duplicate devices
|
|
586
|
+
const deviceMap = new Map<string, DeviceRecord>()
|
|
587
|
+
for (const d of [...recipientDevices, ...ownDevices]) {
|
|
588
|
+
if (d.deviceId !== this.deviceId) { // Exclude sender's own device
|
|
589
|
+
deviceMap.set(d.deviceId, d)
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const devices = Array.from(deviceMap.values())
|
|
533
593
|
|
|
534
594
|
// Send to all devices in background (if sessions exist)
|
|
535
595
|
Promise.allSettled(
|
|
536
596
|
devices.map(async (device) => {
|
|
537
597
|
const { activeSession } = device
|
|
538
|
-
if (!activeSession)
|
|
598
|
+
if (!activeSession) {
|
|
599
|
+
return
|
|
600
|
+
}
|
|
539
601
|
const { event: verifiedEvent } = activeSession.sendEvent(event)
|
|
540
602
|
await this.nostrPublish(verifiedEvent).catch(console.error)
|
|
541
603
|
})
|
|
542
604
|
)
|
|
543
605
|
.then(() => {
|
|
606
|
+
// Store recipient's user record
|
|
544
607
|
this.storeUserRecord(recipientIdentityKey)
|
|
608
|
+
// Also store owner's record if different (for sibling device sessions)
|
|
609
|
+
// This ensures session state is persisted after ratcheting
|
|
610
|
+
// TODO: check if really necessary, if yes, why?
|
|
611
|
+
if (this.ownerPublicKey !== recipientIdentityKey) {
|
|
612
|
+
this.storeUserRecord(this.ownerPublicKey)
|
|
613
|
+
}
|
|
545
614
|
})
|
|
546
615
|
.catch(console.error)
|
|
547
616
|
|
|
@@ -581,33 +650,6 @@ export class SessionManager {
|
|
|
581
650
|
return rumor
|
|
582
651
|
}
|
|
583
652
|
|
|
584
|
-
async revokeDevice(deviceId: string): Promise<void> {
|
|
585
|
-
await this.init()
|
|
586
|
-
|
|
587
|
-
await this.publishDeviceTombstone(deviceId).catch((error) => {
|
|
588
|
-
console.error("Failed to publish device tombstone:", error)
|
|
589
|
-
})
|
|
590
|
-
|
|
591
|
-
await this.cleanupDevice(this.ourPublicKey, deviceId)
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
private async publishDeviceTombstone(deviceId: string): Promise<void> {
|
|
595
|
-
const tags: string[][] = [
|
|
596
|
-
["l", "double-ratchet/invites"],
|
|
597
|
-
["d", `double-ratchet/invites/${deviceId}`],
|
|
598
|
-
]
|
|
599
|
-
|
|
600
|
-
const deletionEvent = {
|
|
601
|
-
content: "",
|
|
602
|
-
kind: INVITE_EVENT_KIND,
|
|
603
|
-
created_at: Math.floor(Date.now() / 1000),
|
|
604
|
-
tags,
|
|
605
|
-
pubkey: this.ourPublicKey,
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
await this.nostrPublish(deletionEvent)
|
|
609
|
-
}
|
|
610
|
-
|
|
611
653
|
private async cleanupDevice(publicKey: string, deviceId: string): Promise<void> {
|
|
612
654
|
const userRecord = this.userRecords.get(publicKey)
|
|
613
655
|
if (!userRecord) return
|
|
@@ -657,6 +699,7 @@ export class SessionManager {
|
|
|
657
699
|
),
|
|
658
700
|
createdAt: device.createdAt,
|
|
659
701
|
staleAt: device.staleAt,
|
|
702
|
+
hasResponderSession: device.hasResponderSession,
|
|
660
703
|
})
|
|
661
704
|
),
|
|
662
705
|
}
|
|
@@ -678,6 +721,7 @@ export class SessionManager {
|
|
|
678
721
|
inactiveSessions: serializedInactive,
|
|
679
722
|
createdAt,
|
|
680
723
|
staleAt,
|
|
724
|
+
hasResponderSession,
|
|
681
725
|
} = deviceData
|
|
682
726
|
|
|
683
727
|
try {
|
|
@@ -698,6 +742,7 @@ export class SessionManager {
|
|
|
698
742
|
inactiveSessions,
|
|
699
743
|
createdAt,
|
|
700
744
|
staleAt,
|
|
745
|
+
hasResponderSession,
|
|
701
746
|
})
|
|
702
747
|
} catch (e) {
|
|
703
748
|
console.error(
|
|
@@ -712,10 +757,6 @@ export class SessionManager {
|
|
|
712
757
|
devices,
|
|
713
758
|
})
|
|
714
759
|
|
|
715
|
-
if (publicKey !== this.ourPublicKey) {
|
|
716
|
-
this.attachInviteTombstoneSubscription(publicKey)
|
|
717
|
-
}
|
|
718
|
-
|
|
719
760
|
for (const device of devices.values()) {
|
|
720
761
|
const { deviceId, activeSession, inactiveSessions, staleAt } = device
|
|
721
762
|
if (!deviceId || staleAt !== undefined) continue
|
|
@@ -751,34 +792,12 @@ export class SessionManager {
|
|
|
751
792
|
|
|
752
793
|
// First migration
|
|
753
794
|
if (!version) {
|
|
754
|
-
//
|
|
755
|
-
// Assume no version prefix
|
|
756
|
-
// Deserialize and serialize to start using persistent createdAt
|
|
757
|
-
// Re-save invites with proper keys
|
|
795
|
+
// Delete old invite data (legacy format no longer supported)
|
|
758
796
|
const oldInvitePrefix = "invite/"
|
|
759
797
|
const inviteKeys = await this.storage.list(oldInvitePrefix)
|
|
760
|
-
await Promise.all(
|
|
761
|
-
inviteKeys.map(async (key) => {
|
|
762
|
-
try {
|
|
763
|
-
const publicKey = key.slice(oldInvitePrefix.length)
|
|
764
|
-
const inviteData = await this.storage.get<string>(key)
|
|
765
|
-
if (inviteData) {
|
|
766
|
-
const newKey = this.userInviteKey(publicKey)
|
|
767
|
-
const invite = Invite.deserialize(inviteData)
|
|
768
|
-
const serializedInvite = invite.serialize()
|
|
769
|
-
await this.storage.put(newKey, serializedInvite)
|
|
770
|
-
await this.storage.del(key)
|
|
771
|
-
}
|
|
772
|
-
} catch (e) {
|
|
773
|
-
console.error("Migration error for invite:", e)
|
|
774
|
-
}
|
|
775
|
-
})
|
|
776
|
-
)
|
|
798
|
+
await Promise.all(inviteKeys.map((key) => this.storage.del(key)))
|
|
777
799
|
|
|
778
|
-
//
|
|
779
|
-
// Assume no version prefix
|
|
780
|
-
// Remove all old sessions as these may have key issues
|
|
781
|
-
// Re-save user records without sessions with proper keys
|
|
800
|
+
// Migrate old user records (clear sessions, keep device records)
|
|
782
801
|
const oldUserRecordPrefix = "user/"
|
|
783
802
|
const sessionKeys = await this.storage.list(oldUserRecordPrefix)
|
|
784
803
|
await Promise.all(
|
|
@@ -806,16 +825,8 @@ export class SessionManager {
|
|
|
806
825
|
})
|
|
807
826
|
)
|
|
808
827
|
|
|
809
|
-
// Set version to 1 so next migration can run
|
|
810
828
|
version = "1"
|
|
811
829
|
await this.storage.put(this.versionKey(), version)
|
|
812
|
-
|
|
813
|
-
return
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
// Future migrations
|
|
817
|
-
if (version === "1") {
|
|
818
|
-
return
|
|
819
830
|
}
|
|
820
831
|
}
|
|
821
832
|
}
|