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/src/InviteList.ts DELETED
@@ -1,333 +0,0 @@
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/UserRecord.ts DELETED
@@ -1,338 +0,0 @@
1
- import { Session } from './Session';
2
- import { NostrSubscribe } from './types';
3
-
4
- interface DeviceRecord {
5
- deviceId: string;
6
- publicKey: string;
7
- activeSession?: Session;
8
- inactiveSessions: Session[];
9
- isStale: boolean;
10
- staleTimestamp?: number;
11
- lastActivity?: number;
12
- }
13
-
14
- /**
15
- * Manages sessions for a single user across multiple devices
16
- * Structure: UserRecord → DeviceRecord → Sessions
17
- */
18
- export class UserRecord {
19
- private deviceRecords: Map<string, DeviceRecord> = new Map();
20
- private isStale: boolean = false;
21
- private staleTimestamp?: number;
22
-
23
- constructor(
24
- public readonly userId: string,
25
- private readonly nostrSubscribe: NostrSubscribe,
26
- ) {
27
- }
28
-
29
- // ============================================================================
30
- // Device Management
31
- // ============================================================================
32
-
33
- /**
34
- * Creates or updates a device record for this user
35
- */
36
- public upsertDevice(deviceId: string, publicKey: string): DeviceRecord {
37
- let record = this.deviceRecords.get(deviceId);
38
-
39
- if (!record) {
40
- record = {
41
- deviceId,
42
- publicKey,
43
- inactiveSessions: [],
44
- isStale: false,
45
- lastActivity: Date.now()
46
- };
47
- this.deviceRecords.set(deviceId, record);
48
- } else if (record.publicKey !== publicKey) {
49
- // Public key changed - mark old sessions as inactive and update
50
- if (record.activeSession) {
51
- record.inactiveSessions.push(record.activeSession);
52
- record.activeSession = undefined;
53
- }
54
- record.publicKey = publicKey;
55
- record.lastActivity = Date.now();
56
- }
57
-
58
- return record;
59
- }
60
-
61
- /**
62
- * Gets a device record by deviceId
63
- */
64
- public getDevice(deviceId: string): DeviceRecord | undefined {
65
- return this.deviceRecords.get(deviceId);
66
- }
67
-
68
- /**
69
- * Gets all device records for this user
70
- */
71
- public getAllDevices(): DeviceRecord[] {
72
- return Array.from(this.deviceRecords.values());
73
- }
74
-
75
- /**
76
- * Gets all active (non-stale) device records
77
- */
78
- public getActiveDevices(): DeviceRecord[] {
79
- if (this.isStale) return [];
80
- return Array.from(this.deviceRecords.values()).filter(device => !device.isStale);
81
- }
82
-
83
- /**
84
- * Removes a device record and closes all its sessions
85
- */
86
- public removeDevice(deviceId: string): boolean {
87
- const record = this.deviceRecords.get(deviceId);
88
- if (!record) return false;
89
-
90
- // Close all sessions for this device
91
- record.activeSession?.close();
92
- record.inactiveSessions.forEach(session => session.close());
93
-
94
- return this.deviceRecords.delete(deviceId);
95
- }
96
-
97
- // ============================================================================
98
- // Session Management
99
- // ============================================================================
100
-
101
- /**
102
- * Adds or updates a session for a specific device
103
- */
104
- public upsertSession(deviceId: string, session: Session, publicKey?: string): void {
105
- const device = this.upsertDevice(deviceId, publicKey || session.state?.theirNextNostrPublicKey || '');
106
-
107
- // If there's an active session, move it to inactive
108
- if (device.activeSession) {
109
- device.inactiveSessions.unshift(device.activeSession);
110
- }
111
-
112
- // Set the new session as active
113
- session.name = deviceId; // Ensure session name matches deviceId
114
- device.activeSession = session;
115
- device.lastActivity = Date.now();
116
- }
117
-
118
- /**
119
- * Gets the active session for a specific device
120
- */
121
- public getActiveSession(deviceId: string): Session | undefined {
122
- const device = this.deviceRecords.get(deviceId);
123
- return device?.isStale ? undefined : device?.activeSession;
124
- }
125
-
126
- /**
127
- * Gets all sessions (active + inactive) for a specific device
128
- */
129
- public getDeviceSessions(deviceId: string): Session[] {
130
- const device = this.deviceRecords.get(deviceId);
131
- if (!device) return [];
132
-
133
- const sessions: Session[] = [];
134
- if (device.activeSession) {
135
- sessions.push(device.activeSession);
136
- }
137
- sessions.push(...device.inactiveSessions);
138
- return sessions;
139
- }
140
-
141
- /**
142
- * Gets all active sessions across all devices for this user
143
- */
144
- public getActiveSessions(): Session[] {
145
- const sessions: Session[] = [];
146
-
147
- for (const device of this.getActiveDevices()) {
148
- if (device.activeSession) {
149
- sessions.push(device.activeSession);
150
- }
151
- }
152
-
153
- // Sort by ability to send messages (prioritize ready sessions)
154
- sessions.sort((a, b) => {
155
- const aCanSend = !!(a.state?.theirNextNostrPublicKey && a.state?.ourCurrentNostrKey);
156
- const bCanSend = !!(b.state?.theirNextNostrPublicKey && b.state?.ourCurrentNostrKey);
157
-
158
- if (aCanSend && !bCanSend) return -1;
159
- if (!aCanSend && bCanSend) return 1;
160
- return 0;
161
- });
162
-
163
- return sessions;
164
- }
165
-
166
- /**
167
- * Gets all sessions (active + inactive) across all devices
168
- */
169
- public getAllSessions(): Session[] {
170
- const sessions: Session[] = [];
171
-
172
- for (const device of this.deviceRecords.values()) {
173
- if (device.activeSession) {
174
- sessions.push(device.activeSession);
175
- }
176
- sessions.push(...device.inactiveSessions);
177
- }
178
-
179
- return sessions;
180
- }
181
-
182
- /**
183
- * Gets sessions that are ready to send messages
184
- */
185
- public getSendableSessions(): Session[] {
186
- return this.getActiveSessions().filter(session =>
187
- !!(session.state?.theirNextNostrPublicKey && session.state?.ourCurrentNostrKey)
188
- );
189
- }
190
-
191
- // ============================================================================
192
- // Stale Management
193
- // ============================================================================
194
-
195
- /**
196
- * Marks a specific device as stale
197
- */
198
- public markDeviceStale(deviceId: string): void {
199
- const device = this.deviceRecords.get(deviceId);
200
- if (device) {
201
- device.isStale = true;
202
- device.staleTimestamp = Date.now();
203
- }
204
- }
205
-
206
- /**
207
- * Marks the entire user record as stale
208
- */
209
- public markUserStale(): void {
210
- this.isStale = true;
211
- this.staleTimestamp = Date.now();
212
- }
213
-
214
- /**
215
- * Removes stale devices and sessions older than maxLatency
216
- */
217
- public pruneStaleRecords(maxLatency: number): void {
218
- const now = Date.now();
219
-
220
- // Remove stale devices
221
- for (const [deviceId, device] of this.deviceRecords.entries()) {
222
- if (device.isStale && device.staleTimestamp &&
223
- (now - device.staleTimestamp) > maxLatency) {
224
- this.removeDevice(deviceId);
225
- }
226
- }
227
-
228
- // Remove entire user record if stale
229
- if (this.isStale && this.staleTimestamp &&
230
- (now - this.staleTimestamp) > maxLatency) {
231
- this.close();
232
- }
233
- }
234
-
235
- // ============================================================================
236
- // Utility Methods
237
- // ============================================================================
238
-
239
- /**
240
- * Gets the most recently active device
241
- */
242
- public getMostActiveDevice(): DeviceRecord | undefined {
243
- const activeDevices = this.getActiveDevices();
244
- if (activeDevices.length === 0) return undefined;
245
-
246
- return activeDevices.reduce((most, current) => {
247
- const mostActivity = most.lastActivity || 0;
248
- const currentActivity = current.lastActivity || 0;
249
- return currentActivity > mostActivity ? current : most;
250
- });
251
- }
252
-
253
- /**
254
- * Gets the preferred session (from most active device)
255
- */
256
- public getPreferredSession(): Session | undefined {
257
- const mostActive = this.getMostActiveDevice();
258
- return mostActive?.activeSession;
259
- }
260
-
261
- /**
262
- * Checks if this user has any active sessions
263
- */
264
- public hasActiveSessions(): boolean {
265
- return this.getActiveSessions().length > 0;
266
- }
267
-
268
- /**
269
- * Gets total count of devices
270
- */
271
- public getDeviceCount(): number {
272
- return this.deviceRecords.size;
273
- }
274
-
275
- /**
276
- * Gets total count of active sessions
277
- */
278
- public getActiveSessionCount(): number {
279
- return this.getActiveSessions().length;
280
- }
281
-
282
- /**
283
- * Cleanup when destroying the user record
284
- */
285
- public close(): void {
286
- for (const device of this.deviceRecords.values()) {
287
- device.activeSession?.close();
288
- device.inactiveSessions.forEach(session => session.close());
289
- }
290
- this.deviceRecords.clear();
291
- }
292
-
293
- // ============================================================================
294
- // Legacy Compatibility Methods
295
- // ============================================================================
296
-
297
- /**
298
- * @deprecated Use upsertDevice instead
299
- */
300
- public conditionalUpdate(deviceId: string, publicKey: string): void {
301
- this.upsertDevice(deviceId, publicKey);
302
- }
303
-
304
- /**
305
- * @deprecated Use upsertSession instead
306
- */
307
- public insertSession(deviceId: string, session: Session): void {
308
- this.upsertSession(deviceId, session);
309
- }
310
-
311
- /**
312
- * Creates a new session for a device
313
- */
314
- public createSession(
315
- deviceId: string,
316
- sharedSecret: Uint8Array,
317
- ourCurrentPrivateKey: Uint8Array,
318
- isInitiator: boolean,
319
- name?: string
320
- ): Session {
321
- const device = this.getDevice(deviceId);
322
- if (!device) {
323
- throw new Error(`No device record found for ${deviceId}`);
324
- }
325
-
326
- const session = Session.init(
327
- this.nostrSubscribe,
328
- device.publicKey,
329
- ourCurrentPrivateKey,
330
- isInitiator,
331
- sharedSecret,
332
- name || deviceId
333
- );
334
-
335
- this.upsertSession(deviceId, session);
336
- return session;
337
- }
338
- }