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.
- 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 +86 -30
- package/dist/SessionManager.d.ts.map +1 -1
- package/dist/StorageAdapter.d.ts +9 -0
- package/dist/StorageAdapter.d.ts.map +1 -1
- package/dist/index.d.ts +4 -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 +3361 -2233
- 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 +780 -347
- package/src/StorageAdapter.ts +64 -6
- package/src/index.ts +5 -1
- package/src/inviteUtils.ts +270 -0
- package/src/types.ts +13 -1
- package/src/utils.ts +3 -3
package/src/StorageAdapter.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Simple async key
|
|
2
|
+
* Simple async key-value storage interface plus implementations.
|
|
3
3
|
*
|
|
4
|
-
* All methods are Promise
|
|
5
|
-
* IndexedDB, SQLite, remote HTTP APIs, etc.
|
|
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 =
|
|
36
|
+
async list(prefix = ""): Promise<string[]> {
|
|
37
37
|
const keys: string[] = []
|
|
38
|
-
|
|
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]
|
|
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
|
])
|