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/StorageAdapter.ts
CHANGED
|
@@ -58,8 +58,7 @@ export class LocalStorageAdapter implements StorageAdapter {
|
|
|
58
58
|
try {
|
|
59
59
|
const item = localStorage.getItem(this.getFullKey(key))
|
|
60
60
|
return item ? JSON.parse(item) : undefined
|
|
61
|
-
} catch
|
|
62
|
-
console.warn(`Failed to get key ${key} from localStorage:`, e)
|
|
61
|
+
} catch {
|
|
63
62
|
return undefined
|
|
64
63
|
}
|
|
65
64
|
}
|
|
@@ -76,8 +75,8 @@ export class LocalStorageAdapter implements StorageAdapter {
|
|
|
76
75
|
async del(key: string): Promise<void> {
|
|
77
76
|
try {
|
|
78
77
|
localStorage.removeItem(this.getFullKey(key))
|
|
79
|
-
} catch
|
|
80
|
-
|
|
78
|
+
} catch {
|
|
79
|
+
// Ignore deletion failures
|
|
81
80
|
}
|
|
82
81
|
}
|
|
83
82
|
|
|
@@ -93,8 +92,8 @@ export class LocalStorageAdapter implements StorageAdapter {
|
|
|
93
92
|
keys.push(key.substring(this.keyPrefix.length))
|
|
94
93
|
}
|
|
95
94
|
}
|
|
96
|
-
} catch
|
|
97
|
-
|
|
95
|
+
} catch {
|
|
96
|
+
// Ignore list failures
|
|
98
97
|
}
|
|
99
98
|
|
|
100
99
|
return keys
|
package/src/index.ts
CHANGED
|
@@ -1,6 +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
7
|
export * from "./SessionManager"
|
|
8
|
+
export * from "./DeviceManager"
|
|
6
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]
|
|
139
|
+
Object.entries(s.skippedKeys).map(([author, entry]) => [
|
|
140
140
|
author,
|
|
141
141
|
{
|
|
142
|
-
headerKeys: entry.headerKeys.map((hk
|
|
142
|
+
headerKeys: entry.headerKeys.map((hk) => new Uint8Array(hk)),
|
|
143
143
|
messageKeys: Object.fromEntries(
|
|
144
|
-
Object.entries(entry.messageKeys).map(([n, mk]
|
|
144
|
+
Object.entries(entry.messageKeys).map(([n, mk]) => [n, new Uint8Array(mk)])
|
|
145
145
|
),
|
|
146
146
|
},
|
|
147
147
|
])
|