Skip to content

Commit ebe7df7

Browse files
authored
feat(nip46): Add support for client-initiated connections in BunkerSigner (#502)
* add: nostrconnect * fix: typo
1 parent 8623531 commit ebe7df7

File tree

2 files changed

+248
-16
lines changed

2 files changed

+248
-16
lines changed

README.md

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -179,13 +179,23 @@ for (let block of nip27.parse(evt.content)) {
179179

180180
### Connecting to a bunker using NIP-46
181181

182+
`BunkerSigner` allows your application to request signatures and other actions from a remote NIP-46 signer, often called a "bunker". There are two primary ways to establish a connection, depending on whether the client or the bunker initiates the connection.
183+
184+
A local secret key is required for the client to communicate securely with the bunker. This key should generally be persisted for the user's session.
185+
182186
```js
183-
import { generateSecretKey, getPublicKey } from '@nostr/tools/pure'
184-
import { BunkerSigner, parseBunkerInput } from '@nostr/tools/nip46'
185-
import { SimplePool } from '@nostr/tools/pool'
187+
import { generateSecretKey } from '@nostr/tools/pure'
186188

187-
// the client needs a local secret key (which is generally persisted) for communicating with the bunker
188189
const localSecretKey = generateSecretKey()
190+
```
191+
192+
### Method 1: Using a Bunker URI (`bunker://`)
193+
194+
This is the bunker-initiated flow. Your client receives a `bunker://` string or a NIP-05 identifier from the user. You use `BunkerSigner.fromBunker()` to create an instance, which returns immediately. You must then explicitly call `await bunker.connect()` to establish the connection with the bunker.
195+
196+
```js
197+
import { BunkerSigner, parseBunkerInput } from '@nostr/tools/nip46'
198+
import { SimplePool } from '@nostr/tools/pool'
189199

190200
// parse a bunker URI
191201
const bunkerPointer = await parseBunkerInput('bunker://abcd...?relay=wss://relay.example.com')
@@ -195,7 +205,7 @@ if (!bunkerPointer) {
195205

196206
// create the bunker instance
197207
const pool = new SimplePool()
198-
const bunker = new BunkerSigner(localSecretKey, bunkerPointer, { pool })
208+
const bunker = BunkerSigner.fromBunker(localSecretKey, bunkerPointer, { pool })
199209
await bunker.connect()
200210

201211
// and use it
@@ -212,6 +222,45 @@ await signer.close()
212222
pool.close([])
213223
```
214224

225+
### Method 2: Using a Client-generated URI (`nostrconnect://`)
226+
227+
This is the client-initiated flow, which generally provides a better user experience for first-time connections (e.g., via QR code). Your client generates a `nostrconnect://` URI and waits for the bunker to connect to it.
228+
229+
`BunkerSigner.fromURI()` is an **asynchronous** method. It returns a `Promise` that resolves only after the bunker has successfully connected. Therefore, the returned signer instance is already fully connected and ready to use, so you **do not** need to call `.connect()` on it.
230+
231+
```js
232+
import { getPublicKey } from '@nostr/tools/pure'
233+
import { BunkerSigner, createNostrConnectURI } from '@nostr/tools/nip46'
234+
import { SimplePool } from '@nostr/tools/pool'
235+
236+
const clientPubkey = getPublicKey(localSecretKey)
237+
238+
// generate a connection URI for the bunker to scan
239+
const connectionUri = createNostrConnectURI({
240+
clientPubkey,
241+
relays: ['wss://relay.damus.io', 'wss://relay.primal.net'],
242+
secret: 'a-random-secret-string', // A secret to verify the bunker's response
243+
name: 'My Awesome App'
244+
})
245+
246+
// wait for the bunker to connect
247+
const pool = new SimplePool()
248+
const signer = await BunkerSigner.fromURI(localSecretKey, connectionUri, { pool })
249+
250+
// and use it
251+
const pubkey = await signer.getPublicKey()
252+
const event = await signer.signEvent({
253+
kind: 1,
254+
created_at: Math.floor(Date.now() / 1000),
255+
tags: [],
256+
content: 'Hello from a client-initiated connection!'
257+
})
258+
259+
// cleanup
260+
await signer.close()
261+
pool.close([])
262+
```
263+
215264
### Parsing thread from any note based on NIP-10
216265

217266
```js

nip46.ts

Lines changed: 194 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,115 @@ export async function queryBunkerProfile(nip05: string): Promise<BunkerPointer |
7777
}
7878
}
7979

80+
export type NostrConnectParams = {
81+
clientPubkey: string
82+
relays: string[]
83+
secret: string
84+
perms?: string[]
85+
name?: string
86+
url?: string
87+
image?: string
88+
}
89+
90+
export type ParsedNostrConnectURI = {
91+
protocol: 'nostrconnect'
92+
clientPubkey: string
93+
params: {
94+
relays: string[]
95+
secret: string
96+
perms?: string[]
97+
name?: string
98+
url?: string
99+
image?: string
100+
};
101+
originalString: string
102+
}
103+
104+
export function createNostrConnectURI(params: NostrConnectParams): string {
105+
if (!params.clientPubkey) {
106+
throw new Error('clientPubkey is required.')
107+
}
108+
if (!params.relays || params.relays.length === 0) {
109+
throw new Error('At least one relay is required.')
110+
}
111+
if (!params.secret) {
112+
throw new Error('secret is required.')
113+
}
114+
115+
const queryParams = new URLSearchParams()
116+
117+
params.relays.forEach(relay => {
118+
queryParams.append('relay', relay)
119+
})
120+
121+
queryParams.append('secret', params.secret)
122+
123+
if (params.perms && params.perms.length > 0) {
124+
queryParams.append('perms', params.perms.join(','))
125+
}
126+
if (params.name) {
127+
queryParams.append('name', params.name)
128+
}
129+
if (params.url) {
130+
queryParams.append('url', params.url)
131+
}
132+
if (params.image) {
133+
queryParams.append('image', params.image)
134+
}
135+
136+
return `nostrconnect://${params.clientPubkey}?${queryParams.toString()}`
137+
}
138+
139+
export function parseNostrConnectURI(uri: string): ParsedNostrConnectURI {
140+
if (!uri.startsWith('nostrconnect://')) {
141+
throw new Error('Invalid nostrconnect URI: Must start with "nostrconnect://".')
142+
}
143+
144+
const [protocolAndPubkey, queryString] = uri.split('?')
145+
if (!protocolAndPubkey || !queryString) {
146+
throw new Error('Invalid nostrconnect URI: Missing query string.')
147+
}
148+
149+
const clientPubkey = protocolAndPubkey.substring('nostrconnect://'.length)
150+
if (!clientPubkey) {
151+
throw new Error('Invalid nostrconnect URI: Missing client-pubkey.')
152+
}
153+
154+
const queryParams = new URLSearchParams(queryString)
155+
156+
const relays = queryParams.getAll('relay')
157+
if (relays.length === 0) {
158+
throw new Error('Invalid nostrconnect URI: Missing "relay" parameter.')
159+
}
160+
161+
const secret = queryParams.get('secret')
162+
if (!secret) {
163+
throw new Error('Invalid nostrconnect URI: Missing "secret" parameter.')
164+
}
165+
166+
const permsString = queryParams.get('perms')
167+
const perms = permsString ? permsString.split(',') : undefined
168+
169+
const name = queryParams.get('name') || undefined
170+
const url = queryParams.get('url') || undefined
171+
const image = queryParams.get('image') || undefined
172+
173+
return {
174+
protocol: 'nostrconnect',
175+
clientPubkey,
176+
params: {
177+
relays,
178+
secret,
179+
perms,
180+
name,
181+
url,
182+
image,
183+
},
184+
originalString: uri,
185+
}
186+
}
187+
188+
80189
export type BunkerSignerParams = {
81190
pool?: AbstractSimplePool
82191
onauth?: (url: string) => void
@@ -97,8 +206,9 @@ export class BunkerSigner implements Signer {
97206
}
98207
private waitingForAuth: { [id: string]: boolean }
99208
private secretKey: Uint8Array
100-
private conversationKey: Uint8Array
101-
public bp: BunkerPointer
209+
// If the client initiates the connection, the two variables below can be filled in later.
210+
private conversationKey!: Uint8Array
211+
public bp!: BunkerPointer
102212

103213
private cachedPubKey: string | undefined
104214

@@ -108,25 +218,98 @@ export class BunkerSigner implements Signer {
108218
* @param remotePubkey - An optional remote public key. This is the key you want to sign as.
109219
* @param secretKey - An optional key pair.
110220
*/
111-
public constructor(clientSecretKey: Uint8Array, bp: BunkerPointer, params: BunkerSignerParams = {}) {
112-
if (bp.relays.length === 0) {
113-
throw new Error('no relays are specified for this bunker')
114-
}
115-
221+
private constructor(clientSecretKey: Uint8Array, params: BunkerSignerParams) {
116222
this.params = params
117223
this.pool = params.pool || new SimplePool()
118224
this.secretKey = clientSecretKey
119-
this.conversationKey = getConversationKey(clientSecretKey, bp.pubkey)
120-
this.bp = bp
121225
this.isOpen = false
122226
this.idPrefix = Math.random().toString(36).substring(7)
123227
this.serial = 0
124228
this.listeners = {}
125229
this.waitingForAuth = {}
230+
}
231+
232+
/**
233+
* [Factory Method 1] Creates a Signer using bunker information (bunker:// URL or NIP-05).
234+
* This method is used when the public key of the bunker is known in advance.
235+
*/
236+
public static fromBunker(
237+
clientSecretKey: Uint8Array,
238+
bp: BunkerPointer,
239+
params: BunkerSignerParams = {}
240+
): BunkerSigner {
241+
if (bp.relays.length === 0) {
242+
throw new Error('No relays specified for this bunker')
243+
}
244+
245+
const signer = new BunkerSigner(clientSecretKey, params)
246+
247+
signer.conversationKey = getConversationKey(clientSecretKey, bp.pubkey)
248+
signer.bp = bp
126249

127-
this.setupSubscription(params)
250+
signer.setupSubscription(params)
251+
return signer
252+
}
253+
254+
/**
255+
* [Factory Method 2] Creates a Signer using a nostrconnect:// URI generated by the client.
256+
* In this method, the bunker initiates the connection by scanning the URI.
257+
*/
258+
public static async fromURI(
259+
clientSecretKey: Uint8Array,
260+
connectionURI: string,
261+
params: BunkerSignerParams = {},
262+
maxWait: number = 300_000,
263+
): Promise<BunkerSigner> {
264+
const signer = new BunkerSigner(clientSecretKey, params)
265+
const parsedURI = parseNostrConnectURI(connectionURI)
266+
const clientPubkey = getPublicKey(clientSecretKey)
267+
268+
return new Promise((resolve, reject) => {
269+
const timer = setTimeout(() => {
270+
sub.close()
271+
reject(new Error(`Connection timed out after ${maxWait / 1000} seconds`))
272+
}, maxWait)
273+
274+
const sub = signer.pool.subscribe(
275+
parsedURI.params.relays,
276+
{ kinds: [NostrConnect], '#p': [clientPubkey] },
277+
{
278+
onevent: async (event: NostrEvent) => {
279+
try {
280+
const tempConvKey = getConversationKey(clientSecretKey, event.pubkey)
281+
const decryptedContent = decrypt(event.content, tempConvKey)
282+
283+
const response = JSON.parse(decryptedContent)
284+
285+
if (response.result === parsedURI.params.secret) {
286+
clearTimeout(timer)
287+
sub.close()
288+
289+
signer.bp = {
290+
pubkey: event.pubkey,
291+
relays: parsedURI.params.relays,
292+
secret: parsedURI.params.secret,
293+
}
294+
signer.conversationKey = getConversationKey(clientSecretKey, event.pubkey)
295+
signer.setupSubscription(params)
296+
resolve(signer)
297+
}
298+
} catch (e) {
299+
console.warn('Failed to process potential connection event', e)
300+
}
301+
},
302+
onclose: () => {
303+
clearTimeout(timer)
304+
reject(new Error('Subscription closed before connection was established.'))
305+
},
306+
maxWait,
307+
}
308+
)
309+
})
128310
}
129311

312+
130313
private setupSubscription(params: BunkerSignerParams) {
131314
const listeners = this.listeners
132315
const waitingForAuth = this.waitingForAuth
@@ -290,7 +473,7 @@ export async function createAccount(
290473
): Promise<BunkerSigner> {
291474
if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email')
292475

293-
let rpc = new BunkerSigner(localSecretKey, bunker.bunkerPointer, params)
476+
let rpc = BunkerSigner.fromBunker(localSecretKey, bunker.bunkerPointer, params)
294477

295478
let pubkey = await rpc.sendRequest('create_account', [username, domain, email || ''])
296479

0 commit comments

Comments
 (0)