nostr-double-ratchet 0.0.36 → 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.
@@ -1,8 +1,8 @@
1
1
  /*
2
- * Simple async key value storage interface plus an in memory implementation.
2
+ * Simple async key-value storage interface plus implementations.
3
3
  *
4
- * All methods are Promise based to accommodate back ends like
5
- * IndexedDB, SQLite, remote HTTP APIs, etc. For environments where you only
4
+ * All methods are Promise-based to accommodate back-ends like
5
+ * IndexedDB, SQLite, remote HTTP APIs, etc. For environments where you only
6
6
  * need ephemeral data (tests, Node scripts) the InMemoryStorageAdapter can be
7
7
  * used directly.
8
8
  */
@@ -33,11 +33,69 @@ export class InMemoryStorageAdapter implements StorageAdapter {
33
33
  this.store.delete(key)
34
34
  }
35
35
 
36
- async list(prefix = ''): Promise<string[]> {
36
+ async list(prefix = ""): Promise<string[]> {
37
37
  const keys: string[] = []
38
- for (const k of this.store.keys()) {
38
+ const storeKeys = Array.from(this.store.keys())
39
+ for (const k of storeKeys) {
39
40
  if (k.startsWith(prefix)) keys.push(k)
40
41
  }
41
42
  return keys
42
43
  }
43
- }
44
+ }
45
+
46
+ export class LocalStorageAdapter implements StorageAdapter {
47
+ private keyPrefix: string
48
+
49
+ constructor(keyPrefix = "session_") {
50
+ this.keyPrefix = keyPrefix
51
+ }
52
+
53
+ private getFullKey(key: string): string {
54
+ return `${this.keyPrefix}${key}`
55
+ }
56
+
57
+ async get<T = unknown>(key: string): Promise<T | undefined> {
58
+ try {
59
+ const item = localStorage.getItem(this.getFullKey(key))
60
+ return item ? JSON.parse(item) : undefined
61
+ } catch {
62
+ return undefined
63
+ }
64
+ }
65
+
66
+ async put<T = unknown>(key: string, value: T): Promise<void> {
67
+ try {
68
+ localStorage.setItem(this.getFullKey(key), JSON.stringify(value))
69
+ } catch (e) {
70
+ console.error(`Failed to put key ${key} to localStorage:`, e)
71
+ throw e
72
+ }
73
+ }
74
+
75
+ async del(key: string): Promise<void> {
76
+ try {
77
+ localStorage.removeItem(this.getFullKey(key))
78
+ } catch {
79
+ // Ignore deletion failures
80
+ }
81
+ }
82
+
83
+ async list(prefix = ""): Promise<string[]> {
84
+ const keys: string[] = []
85
+ const searchPrefix = this.getFullKey(prefix)
86
+
87
+ try {
88
+ for (let i = 0; i < localStorage.length; i++) {
89
+ const key = localStorage.key(i)
90
+ if (key && key.startsWith(searchPrefix)) {
91
+ // Remove our prefix to return the original key
92
+ keys.push(key.substring(this.keyPrefix.length))
93
+ }
94
+ }
95
+ } catch {
96
+ // Ignore list failures
97
+ }
98
+
99
+ return keys
100
+ }
101
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  export * from "./Session"
2
2
  export * from "./Invite"
3
+ export * from "./InviteList"
4
+ export * from "./inviteUtils"
3
5
  export * from "./types"
4
6
  export * from "./utils"
5
- export * from "./SessionManager"
7
+ export * from "./SessionManager"
8
+ export * from "./DeviceManager"
9
+ export * from "./StorageAdapter"
@@ -0,0 +1,270 @@
1
+ import { generateSecretKey, getPublicKey, nip44 } from 'nostr-tools'
2
+ import { getConversationKey } from 'nostr-tools/nip44'
3
+ import { hexToBytes, bytesToHex } from '@noble/hashes/utils'
4
+ import { Session } from './Session'
5
+ import { NostrSubscribe, INVITE_RESPONSE_KIND, EncryptFunction, DecryptFunction, KeyPair } from './types'
6
+
7
+ /**
8
+ * Device payload for QR code / text code sharing.
9
+ */
10
+ export interface DevicePayload {
11
+ /** Ephemeral public key (64 hex chars) */
12
+ ephemeralPubkey: string
13
+ /** Shared secret (64 hex chars) */
14
+ sharedSecret: string
15
+ /** Device ID (16 hex chars) */
16
+ deviceId: string
17
+ /** Human-readable device label */
18
+ deviceLabel: string
19
+ /** Identity public key for this device (64 hex chars) */
20
+ identityPubkey: string
21
+ }
22
+
23
+ /**
24
+ * Generates a new ephemeral keypair for invites.
25
+ * @returns A keypair with publicKey (hex string) and privateKey (Uint8Array)
26
+ */
27
+ export function generateEphemeralKeypair(): KeyPair {
28
+ const privateKey = generateSecretKey()
29
+ const publicKey = getPublicKey(privateKey)
30
+ return { publicKey, privateKey }
31
+ }
32
+
33
+ /**
34
+ * Generates a new shared secret for invite handshakes.
35
+ * @returns A 64-character hex string (32 bytes)
36
+ */
37
+ export function generateSharedSecret(): string {
38
+ return bytesToHex(generateSecretKey())
39
+ }
40
+
41
+ /**
42
+ * Generates a unique device ID.
43
+ * @returns A random device ID string
44
+ */
45
+ export function generateDeviceId(): string {
46
+ return bytesToHex(generateSecretKey()).slice(0, 16)
47
+ }
48
+
49
+ export interface EncryptInviteResponseParams {
50
+ /** The invitee's session public key */
51
+ inviteeSessionPublicKey: string
52
+ /** The invitee's identity public key */
53
+ inviteePublicKey: string
54
+ /** The invitee's identity private key (optional if encrypt function provided) */
55
+ inviteePrivateKey?: Uint8Array
56
+ /** The inviter's identity public key */
57
+ inviterPublicKey: string
58
+ /** The inviter's ephemeral public key */
59
+ inviterEphemeralPublicKey: string
60
+ /** The shared secret for the invite */
61
+ sharedSecret: string
62
+ /** Optional device ID for the invitee's device */
63
+ deviceId?: string
64
+ /** Optional custom encrypt function */
65
+ encrypt?: EncryptFunction
66
+ }
67
+
68
+ export interface EncryptedInviteResponse {
69
+ /** The inner event containing the encrypted payload */
70
+ innerEvent: {
71
+ pubkey: string
72
+ content: string
73
+ created_at: number
74
+ }
75
+ /** The outer envelope event */
76
+ envelope: {
77
+ kind: number
78
+ pubkey: string
79
+ content: string
80
+ created_at: number
81
+ tags: string[][]
82
+ }
83
+ /** The random sender's public key used for the envelope */
84
+ randomSenderPublicKey: string
85
+ /** The random sender's private key used for the envelope */
86
+ randomSenderPrivateKey: Uint8Array
87
+ }
88
+
89
+ const TWO_DAYS = 2 * 24 * 60 * 60
90
+ const now = () => Math.round(Date.now() / 1000)
91
+ const randomNow = () => Math.round(now() - Math.random() * TWO_DAYS)
92
+
93
+ /**
94
+ * Encrypts an invite response with two-layer encryption.
95
+ *
96
+ * Layer 1 (inner): Payload encrypted with DH key, then encrypted with shared secret.
97
+ * Layer 2 (outer): Envelope encrypted with random key -> inviter ephemeral key.
98
+ */
99
+ export async function encryptInviteResponse(params: EncryptInviteResponseParams): Promise<EncryptedInviteResponse> {
100
+ const {
101
+ inviteeSessionPublicKey,
102
+ inviteePublicKey,
103
+ inviteePrivateKey,
104
+ inviterPublicKey,
105
+ inviterEphemeralPublicKey,
106
+ sharedSecret,
107
+ deviceId,
108
+ encrypt,
109
+ } = params
110
+
111
+ const sharedSecretBytes = hexToBytes(sharedSecret)
112
+
113
+ // Create the encrypt function
114
+ const encryptFn = encrypt ?? (async (plaintext: string, pubkey: string) => {
115
+ if (!inviteePrivateKey) {
116
+ throw new Error('inviteePrivateKey is required when encrypt function is not provided')
117
+ }
118
+ return nip44.encrypt(plaintext, getConversationKey(inviteePrivateKey, pubkey))
119
+ })
120
+
121
+ // Create the payload
122
+ const payload = JSON.stringify({
123
+ sessionKey: inviteeSessionPublicKey,
124
+ deviceId: deviceId,
125
+ })
126
+
127
+ // Encrypt with DH key (invitee -> inviter)
128
+ const dhEncrypted = await encryptFn(payload, inviterPublicKey)
129
+
130
+ // Encrypt with shared secret
131
+ const innerEvent = {
132
+ pubkey: inviteePublicKey,
133
+ content: nip44.encrypt(dhEncrypted, sharedSecretBytes),
134
+ created_at: now(),
135
+ }
136
+
137
+ // Create a random keypair for the envelope sender
138
+ const randomSenderPrivateKey = generateSecretKey()
139
+ const randomSenderPublicKey = getPublicKey(randomSenderPrivateKey)
140
+
141
+ // Encrypt the inner event with the random key -> inviter ephemeral key
142
+ const innerJson = JSON.stringify(innerEvent)
143
+ const envelope = {
144
+ kind: INVITE_RESPONSE_KIND,
145
+ pubkey: randomSenderPublicKey,
146
+ content: nip44.encrypt(innerJson, getConversationKey(randomSenderPrivateKey, inviterEphemeralPublicKey)),
147
+ created_at: randomNow(),
148
+ tags: [['p', inviterEphemeralPublicKey]],
149
+ }
150
+
151
+ return {
152
+ innerEvent,
153
+ envelope,
154
+ randomSenderPublicKey,
155
+ randomSenderPrivateKey,
156
+ }
157
+ }
158
+
159
+ export interface DecryptInviteResponseParams {
160
+ /** The encrypted envelope content */
161
+ envelopeContent: string
162
+ /** The envelope sender's public key */
163
+ envelopeSenderPubkey: string
164
+ /** The inviter's ephemeral private key */
165
+ inviterEphemeralPrivateKey: Uint8Array
166
+ /** The inviter's identity private key (optional if decrypt function provided) */
167
+ inviterPrivateKey?: Uint8Array
168
+ /** The shared secret for the invite */
169
+ sharedSecret: string
170
+ /** Optional custom decrypt function */
171
+ decrypt?: DecryptFunction
172
+ }
173
+
174
+ export interface DecryptedInviteResponse {
175
+ /** The invitee's identity public key */
176
+ inviteeIdentity: string
177
+ /** The invitee's session public key */
178
+ inviteeSessionPublicKey: string
179
+ /** Optional device ID for the invitee's device */
180
+ deviceId?: string
181
+ }
182
+
183
+ /**
184
+ * Decrypts an invite response.
185
+ */
186
+ export async function decryptInviteResponse(params: DecryptInviteResponseParams): Promise<DecryptedInviteResponse> {
187
+ const {
188
+ envelopeContent,
189
+ envelopeSenderPubkey,
190
+ inviterEphemeralPrivateKey,
191
+ inviterPrivateKey,
192
+ sharedSecret,
193
+ decrypt,
194
+ } = params
195
+
196
+ const sharedSecretBytes = hexToBytes(sharedSecret)
197
+
198
+ // Decrypt the outer envelope
199
+ const decrypted = nip44.decrypt(
200
+ envelopeContent,
201
+ getConversationKey(inviterEphemeralPrivateKey, envelopeSenderPubkey)
202
+ )
203
+ const innerEvent = JSON.parse(decrypted)
204
+
205
+ const inviteeIdentity = innerEvent.pubkey
206
+
207
+ // Decrypt the inner content using shared secret
208
+ const dhEncrypted = nip44.decrypt(innerEvent.content, sharedSecretBytes)
209
+
210
+ // Create the decrypt function
211
+ const decryptFn = decrypt ?? (async (ciphertext: string, pubkey: string) => {
212
+ if (!inviterPrivateKey) {
213
+ throw new Error('inviterPrivateKey is required when decrypt function is not provided')
214
+ }
215
+ return nip44.decrypt(ciphertext, getConversationKey(inviterPrivateKey, pubkey))
216
+ })
217
+
218
+ // Decrypt using DH key
219
+ const decryptedPayload = await decryptFn(dhEncrypted, inviteeIdentity)
220
+
221
+ let inviteeSessionPublicKey: string
222
+ let deviceId: string | undefined
223
+
224
+ try {
225
+ const parsed = JSON.parse(decryptedPayload)
226
+ inviteeSessionPublicKey = parsed.sessionKey
227
+ deviceId = parsed.deviceId
228
+ } catch {
229
+ // Backward compatibility: plain session key
230
+ inviteeSessionPublicKey = decryptedPayload
231
+ }
232
+
233
+ return {
234
+ inviteeIdentity,
235
+ inviteeSessionPublicKey,
236
+ deviceId,
237
+ }
238
+ }
239
+
240
+ export interface CreateSessionFromAcceptParams {
241
+ /** Nostr subscription function */
242
+ nostrSubscribe: NostrSubscribe
243
+ /** The other party's public key */
244
+ theirPublicKey: string
245
+ /** Our session private key */
246
+ ourSessionPrivateKey: Uint8Array
247
+ /** The shared secret (hex string) */
248
+ sharedSecret: string
249
+ /** Whether we are the sender (initiator) */
250
+ isSender: boolean
251
+ /** Optional session name */
252
+ name?: string
253
+ }
254
+
255
+ /**
256
+ * Creates a Session from invite acceptance parameters.
257
+ */
258
+ export function createSessionFromAccept(params: CreateSessionFromAcceptParams): Session {
259
+ const {
260
+ nostrSubscribe,
261
+ theirPublicKey,
262
+ ourSessionPrivateKey,
263
+ sharedSecret,
264
+ isSender,
265
+ name,
266
+ } = params
267
+
268
+ const sharedSecretBytes = hexToBytes(sharedSecret)
269
+ return Session.init(nostrSubscribe, theirPublicKey, ourSessionPrivateKey, isSender, sharedSecretBytes, name)
270
+ }
package/src/types.ts CHANGED
@@ -68,11 +68,18 @@ export type Unsubscribe = () => void;
68
68
  export type NostrSubscribe = (_filter: Filter, _onEvent: (_e: VerifiedEvent) => void) => Unsubscribe;
69
69
  export type EncryptFunction = (_plaintext: string, _pubkey: string) => Promise<string>;
70
70
  export type DecryptFunction = (_ciphertext: string, _pubkey: string) => Promise<string>;
71
+
72
+ /**
73
+ * Identity key for cryptographic operations.
74
+ * Either a raw private key (Uint8Array) or encrypt/decrypt functions for extension login (NIP-07).
75
+ */
76
+ export type IdentityKey = Uint8Array | { encrypt: EncryptFunction; decrypt: DecryptFunction };
77
+
71
78
  export type NostrPublish = (_event: UnsignedEvent) => Promise<VerifiedEvent>;
72
79
 
73
80
  export type Rumor = UnsignedEvent & { id: string }
74
81
 
75
- /**
82
+ /**
76
83
  * Callback function for handling decrypted messages
77
84
  * @param _event - The decrypted message object (Rumor)
78
85
  * @param _outerEvent - The outer Nostr event (VerifiedEvent)
@@ -91,6 +98,11 @@ export const INVITE_EVENT_KIND = 30078;
91
98
 
92
99
  export const INVITE_RESPONSE_KIND = 1059;
93
100
 
101
+ /**
102
+ * Invite list event kind (replaceable - one per user)
103
+ */
104
+ export const INVITE_LIST_EVENT_KIND = 10078;
105
+
94
106
  export const CHAT_MESSAGE_KIND = 14;
95
107
 
96
108
  export const MAX_SKIP = 100;
package/src/utils.ts CHANGED
@@ -136,12 +136,12 @@ export function deepCopyState(s: SessionState): SessionState {
136
136
  receivingChainMessageNumber: s.receivingChainMessageNumber,
137
137
  previousSendingChainMessageCount: s.previousSendingChainMessageCount,
138
138
  skippedKeys: Object.fromEntries(
139
- Object.entries(s.skippedKeys).map(([author, entry]: any) => [
139
+ Object.entries(s.skippedKeys).map(([author, entry]) => [
140
140
  author,
141
141
  {
142
- headerKeys: entry.headerKeys.map((hk: Uint8Array) => new Uint8Array(hk)),
142
+ headerKeys: entry.headerKeys.map((hk) => new Uint8Array(hk)),
143
143
  messageKeys: Object.fromEntries(
144
- Object.entries(entry.messageKeys).map(([n, mk]: any) => [n, new Uint8Array(mk)])
144
+ Object.entries(entry.messageKeys).map(([n, mk]) => [n, new Uint8Array(mk)])
145
145
  ),
146
146
  },
147
147
  ])