bunker.signer.ts raw
1 /**
2 * NIP-46 Bunker Signer
3 *
4 * Implements remote signing via NIP-46 protocol.
5 * The signer connects to a bunker WebSocket and
6 * requests signing operations.
7 */
8
9 import { ISigner, TDraftEvent } from '@/types'
10 import * as utils from '@noble/curves/abstract/utils'
11 import { secp256k1 } from '@noble/curves/secp256k1'
12 import { Event, VerifiedEvent, getPublicKey as nGetPublicKey, nip04, finalizeEvent } from 'nostr-tools'
13
14 // NIP-46 methods
15 const NIP46_METHOD = {
16 CONNECT: 'connect',
17 GET_PUBLIC_KEY: 'get_public_key',
18 SIGN_EVENT: 'sign_event',
19 NIP04_ENCRYPT: 'nip04_encrypt',
20 NIP04_DECRYPT: 'nip04_decrypt',
21 NIP44_ENCRYPT: 'nip44_encrypt',
22 NIP44_DECRYPT: 'nip44_decrypt',
23 PING: 'ping'
24 } as const
25
26 type NIP46Method = (typeof NIP46_METHOD)[keyof typeof NIP46_METHOD]
27
28 // NIP-46 request format
29 interface NIP46Request {
30 id: string
31 method: NIP46Method
32 params: string[]
33 }
34
35 // NIP-46 response format
36 interface NIP46Response {
37 id: string
38 result?: string
39 error?: string
40 }
41
42 // Pending request tracker
43 interface PendingRequest {
44 resolve: (value: string) => void
45 reject: (error: Error) => void
46 timeout: ReturnType<typeof setTimeout>
47 }
48
49 /**
50 * Generate a random request ID.
51 */
52 function generateRequestId(): string {
53 const bytes = crypto.getRandomValues(new Uint8Array(16))
54 return utils.bytesToHex(bytes)
55 }
56
57 /**
58 * Parse a bunker URL (bunker://<pubkey>?relay=<url>&secret=<secret>).
59 */
60 export function parseBunkerUrl(url: string): {
61 pubkey: string
62 relays: string[]
63 secret?: string
64 } {
65 if (!url.startsWith('bunker://')) {
66 throw new Error('Invalid bunker URL: must start with bunker://')
67 }
68
69 const withoutPrefix = url.slice('bunker://'.length)
70 const [pubkeyPart, queryPart] = withoutPrefix.split('?')
71
72 if (!pubkeyPart || pubkeyPart.length !== 64) {
73 throw new Error('Invalid bunker URL: missing or invalid pubkey')
74 }
75
76 const params = new URLSearchParams(queryPart || '')
77 const relays = params.getAll('relay')
78 const secret = params.get('secret') || undefined
79
80 if (relays.length === 0) {
81 throw new Error('Invalid bunker URL: no relay specified')
82 }
83
84 return {
85 pubkey: pubkeyPart,
86 relays,
87 secret
88 }
89 }
90
91 /**
92 * Parse a nostr+connect URL (nostr+connect://<relay-url>?pubkey=<client-pubkey>&secret=<secret>).
93 * This is the format that signers (like Amber) scan to connect to a client.
94 */
95 export function parseNostrConnectUrl(url: string): {
96 relay: string
97 pubkey?: string
98 secret?: string
99 } {
100 if (!url.startsWith('nostr+connect://')) {
101 throw new Error('Invalid nostr+connect URL: must start with nostr+connect://')
102 }
103
104 const withoutPrefix = url.slice('nostr+connect://'.length)
105 const [relayPart, queryPart] = withoutPrefix.split('?')
106
107 if (!relayPart) {
108 throw new Error('Invalid nostr+connect URL: missing relay')
109 }
110
111 const params = new URLSearchParams(queryPart || '')
112 const pubkey = params.get('pubkey') || undefined
113 const secret = params.get('secret') || undefined
114
115 return {
116 relay: relayPart,
117 pubkey,
118 secret
119 }
120 }
121
122 /**
123 * Build a nostr+connect URL for signers to scan.
124 * @param relay - The relay URL (without ws:// prefix, will be added)
125 * @param pubkey - The client's ephemeral pubkey for this session
126 * @param secret - Optional secret for the handshake
127 */
128 export function buildNostrConnectUrl(relay: string, pubkey: string, secret?: string): string {
129 // Ensure relay URL uses the relay host without protocol
130 let relayHost = relay
131 .replace('wss://', '')
132 .replace('ws://', '')
133 .replace('https://', '')
134 .replace('http://', '')
135 .replace(/\/$/, '')
136
137 const params = new URLSearchParams()
138 params.set('pubkey', pubkey)
139 if (secret) {
140 params.set('secret', secret)
141 }
142 return `nostr+connect://${relayHost}?${params.toString()}`
143 }
144
145 /**
146 * Build a bunker URL from components.
147 */
148 export function buildBunkerUrl(pubkey: string, relays: string[], secret?: string): string {
149 const params = new URLSearchParams()
150 relays.forEach((relay) => params.append('relay', relay))
151 if (secret) {
152 params.set('secret', secret)
153 }
154 return `bunker://${pubkey}?${params.toString()}`
155 }
156
157 export class BunkerSigner implements ISigner {
158 private bunkerPubkey: string
159 private relayUrls: string[]
160 private connectionSecret?: string
161 private localPrivkey: Uint8Array
162 private localPubkey: string
163 private remotePubkey: string | null = null
164 private ws: WebSocket | null = null
165 private pendingRequests = new Map<string, PendingRequest>()
166 private connected = false
167 private requestTimeout = 30000 // 30 seconds
168
169 // Whether we're waiting for signer to connect (reverse flow)
170 private awaitingConnection = false
171 private connectionResolve: ((pubkey: string) => void) | null = null
172
173 /**
174 * Create a BunkerSigner.
175 * @param bunkerPubkey - The bunker's public key (hex)
176 * @param relayUrls - Relay URLs to connect to
177 * @param connectionSecret - Optional connection secret for initial handshake
178 */
179 constructor(bunkerPubkey: string, relayUrls: string[], connectionSecret?: string) {
180 this.bunkerPubkey = bunkerPubkey
181 this.relayUrls = relayUrls
182 this.connectionSecret = connectionSecret
183
184 // Generate local ephemeral keypair for NIP-46 communication
185 this.localPrivkey = secp256k1.utils.randomPrivateKey()
186 this.localPubkey = nGetPublicKey(this.localPrivkey)
187 }
188
189 /**
190 * Create a BunkerSigner that waits for a signer (like Amber) to connect.
191 * Returns the nostr+connect URL to display as QR code and a promise for the connected signer.
192 *
193 * @param relayUrl - The relay URL for the connection
194 * @param secret - Optional secret for the handshake
195 * @param timeout - Connection timeout in ms (default 120000 = 2 minutes)
196 */
197 static async awaitSignerConnection(
198 relayUrl: string,
199 secret?: string,
200 timeout = 120000
201 ): Promise<{ connectUrl: string; signer: Promise<BunkerSigner> }> {
202 // Generate ephemeral keypair for this session
203 const localPrivkey = secp256k1.utils.randomPrivateKey()
204 const localPubkey = nGetPublicKey(localPrivkey)
205
206 // Generate secret if not provided
207 const connectionSecret = secret || generateRequestId()
208
209 // Build the nostr+connect URL for signer to scan
210 const connectUrl = buildNostrConnectUrl(relayUrl, localPubkey, connectionSecret)
211
212 // Create signer instance (bunkerPubkey will be set when signer connects)
213 const signer = new BunkerSigner('', [relayUrl], connectionSecret)
214 signer.localPrivkey = localPrivkey
215 signer.localPubkey = localPubkey
216 signer.awaitingConnection = true
217
218 // Return URL immediately, signer promise resolves when connected
219 const signerPromise = new Promise<BunkerSigner>((resolve, reject) => {
220 signer.connectionResolve = (signerPubkey: string) => {
221 signer.bunkerPubkey = signerPubkey
222 // Do NOT set remotePubkey here - it must be fetched via get_public_key
223 // The signerPubkey from the connect event is the bunker's communication pubkey,
224 // not necessarily the user's signing pubkey
225 signer.awaitingConnection = false
226 resolve(signer)
227 }
228 // Set timeout
229 setTimeout(() => {
230 if (signer.awaitingConnection) {
231 signer.disconnect()
232 reject(new Error('Connection timeout waiting for signer'))
233 }
234 }, timeout)
235
236 // Connect to relay and wait
237 signer.connectAndWait(relayUrl).catch(reject)
238 })
239
240 return { connectUrl, signer: signerPromise }
241 }
242
243 /**
244 * Connect to relay and wait for signer to initiate connection.
245 */
246 private async connectAndWait(relayUrl: string): Promise<void> {
247 await this.connectToRelayAndListen(relayUrl)
248 }
249
250 /**
251 * Connect to relay and listen for incoming connect requests.
252 */
253 private async connectToRelayAndListen(relayUrl: string): Promise<void> {
254 return new Promise((resolve, reject) => {
255 let wsUrl = relayUrl
256 if (relayUrl.startsWith('http://')) {
257 wsUrl = 'ws://' + relayUrl.slice(7)
258 } else if (relayUrl.startsWith('https://')) {
259 wsUrl = 'wss://' + relayUrl.slice(8)
260 } else if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) {
261 wsUrl = 'wss://' + relayUrl
262 }
263
264 const ws = new WebSocket(wsUrl)
265
266 const timeout = setTimeout(() => {
267 ws.close()
268 reject(new Error('Connection timeout'))
269 }, 10000)
270
271 ws.onopen = () => {
272 clearTimeout(timeout)
273 this.ws = ws
274 this.connected = true
275
276 // Subscribe to events for our local pubkey
277 const subId = generateRequestId()
278 ws.send(
279 JSON.stringify([
280 'REQ',
281 subId,
282 {
283 kinds: [24133],
284 '#p': [this.localPubkey],
285 since: Math.floor(Date.now() / 1000) - 60
286 }
287 ])
288 )
289
290 resolve()
291 }
292
293 ws.onerror = () => {
294 clearTimeout(timeout)
295 reject(new Error('WebSocket error'))
296 }
297
298 ws.onclose = () => {
299 this.connected = false
300 this.ws = null
301 }
302
303 ws.onmessage = (event) => {
304 this.handleMessage(event.data)
305 }
306 })
307 }
308
309 /**
310 * Get the local public key (for displaying in nostr+connect URL).
311 */
312 getLocalPubkey(): string {
313 return this.localPubkey
314 }
315
316 /**
317 * Initialize connection to the bunker.
318 */
319 async init(): Promise<void> {
320 // Connect to first available relay
321 for (const relayUrl of this.relayUrls) {
322 try {
323 await this.connectToRelay(relayUrl)
324 break
325 } catch (err) {
326 console.warn(`Failed to connect to ${relayUrl}:`, err)
327 }
328 }
329
330 if (!this.connected) {
331 throw new Error('Failed to connect to any bunker relay')
332 }
333
334 // Perform NIP-46 connect handshake
335 await this.connect()
336 }
337
338 /**
339 * Connect to a relay WebSocket.
340 */
341 private async connectToRelay(relayUrl: string): Promise<void> {
342 return new Promise((resolve, reject) => {
343 // Convert ws:// or wss:// URL
344 let wsUrl = relayUrl
345 if (relayUrl.startsWith('http://')) {
346 wsUrl = 'ws://' + relayUrl.slice(7)
347 } else if (relayUrl.startsWith('https://')) {
348 wsUrl = 'wss://' + relayUrl.slice(8)
349 } else if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) {
350 wsUrl = 'wss://' + relayUrl
351 }
352
353 const ws = new WebSocket(wsUrl)
354
355 const timeout = setTimeout(() => {
356 ws.close()
357 reject(new Error('Connection timeout'))
358 }, 10000)
359
360 ws.onopen = () => {
361 clearTimeout(timeout)
362 this.ws = ws
363 this.connected = true
364
365 // Subscribe to responses for our local pubkey
366 const subId = generateRequestId()
367 ws.send(
368 JSON.stringify([
369 'REQ',
370 subId,
371 {
372 kinds: [24133], // NIP-46 response kind
373 '#p': [this.localPubkey],
374 since: Math.floor(Date.now() / 1000) - 60
375 }
376 ])
377 )
378
379 resolve()
380 }
381
382 ws.onerror = () => {
383 clearTimeout(timeout)
384 reject(new Error('WebSocket error'))
385 }
386
387 ws.onclose = () => {
388 this.connected = false
389 this.ws = null
390 }
391
392 ws.onmessage = (event) => {
393 this.handleMessage(event.data)
394 }
395 })
396 }
397
398 /**
399 * Handle incoming WebSocket messages.
400 */
401 private async handleMessage(data: string): Promise<void> {
402 try {
403 const msg = JSON.parse(data)
404 if (!Array.isArray(msg)) return
405
406 const [type, ...rest] = msg
407
408 if (type === 'EVENT') {
409 const [, event] = rest as [string, Event]
410 if (event.kind === 24133) {
411 await this.handleNIP46Response(event)
412 }
413 } else if (type === 'OK') {
414 // Event published confirmation
415 } else if (type === 'NOTICE') {
416 console.warn('Relay notice:', rest[0])
417 }
418 } catch (err) {
419 console.error('Failed to parse message:', err)
420 }
421 }
422
423 /**
424 * Handle NIP-46 response event.
425 */
426 private async handleNIP46Response(event: Event): Promise<void> {
427 try {
428 // Decrypt the content with NIP-04
429 const decrypted = await nip04.decrypt(this.localPrivkey, event.pubkey, event.content)
430 const parsed = JSON.parse(decrypted)
431
432 // Check if this is an incoming connect request (signer initiating connection)
433 if (this.awaitingConnection && parsed.method === 'connect') {
434 const request = parsed as NIP46Request
435 console.log('Received connect request from signer:', event.pubkey)
436
437 // Verify secret if we have one
438 if (this.connectionSecret) {
439 const providedSecret = request.params[1] // Second param is the secret
440 if (providedSecret !== this.connectionSecret) {
441 console.warn('Connect request has wrong secret, ignoring')
442 return
443 }
444 }
445
446 // Send ack response
447 const response: NIP46Response = {
448 id: request.id,
449 result: 'ack'
450 }
451 const encrypted = await nip04.encrypt(this.localPrivkey, event.pubkey, JSON.stringify(response))
452 const responseEvent: TDraftEvent = {
453 kind: 24133,
454 created_at: Math.floor(Date.now() / 1000),
455 content: encrypted,
456 tags: [['p', event.pubkey]]
457 }
458 const signedResponse = finalizeEvent(responseEvent, this.localPrivkey)
459 this.ws?.send(JSON.stringify(['EVENT', signedResponse]))
460
461 // Resolve the connection promise
462 if (this.connectionResolve) {
463 this.connectionResolve(event.pubkey)
464 }
465 return
466 }
467
468 // Handle as normal response
469 const response = parsed as NIP46Response
470 const pending = this.pendingRequests.get(response.id)
471
472 if (pending) {
473 clearTimeout(pending.timeout)
474 this.pendingRequests.delete(response.id)
475
476 if (response.error) {
477 pending.reject(new Error(response.error))
478 } else if (response.result !== undefined) {
479 pending.resolve(response.result)
480 } else {
481 pending.reject(new Error('Empty response'))
482 }
483 }
484 } catch (err) {
485 console.error('Failed to handle NIP-46 response:', err)
486 }
487 }
488
489 /**
490 * Send a NIP-46 request and wait for response.
491 */
492 private async sendRequest(method: NIP46Method, params: string[] = []): Promise<string> {
493 if (!this.ws || !this.connected) {
494 throw new Error('Not connected to bunker')
495 }
496
497 const request: NIP46Request = {
498 id: generateRequestId(),
499 method,
500 params
501 }
502
503 // Encrypt with NIP-04 to the bunker's pubkey
504 const encrypted = await nip04.encrypt(this.localPrivkey, this.bunkerPubkey, JSON.stringify(request))
505
506 // Create NIP-46 request event
507 const draftEvent: TDraftEvent = {
508 kind: 24133,
509 created_at: Math.floor(Date.now() / 1000),
510 content: encrypted,
511 tags: [['p', this.bunkerPubkey]]
512 }
513
514 const signedEvent = finalizeEvent(draftEvent, this.localPrivkey)
515
516 // Send to relay
517 this.ws.send(JSON.stringify(['EVENT', signedEvent]))
518
519 // Wait for response
520 return new Promise((resolve, reject) => {
521 const timeout = setTimeout(() => {
522 this.pendingRequests.delete(request.id)
523 reject(new Error('Request timeout'))
524 }, this.requestTimeout)
525
526 this.pendingRequests.set(request.id, { resolve, reject, timeout })
527 })
528 }
529
530 /**
531 * Perform NIP-46 connect handshake.
532 */
533 private async connect(): Promise<void> {
534 const params: string[] = [this.localPubkey]
535 if (this.connectionSecret) {
536 params.push(this.connectionSecret)
537 }
538
539 const result = await this.sendRequest(NIP46_METHOD.CONNECT, params)
540 if (result !== 'ack') {
541 throw new Error(`Connect failed: ${result}`)
542 }
543 }
544
545 /**
546 * Get the public key of the user (from the bunker).
547 */
548 async getPublicKey(): Promise<string> {
549 if (this.remotePubkey) {
550 return this.remotePubkey
551 }
552
553 const pubkey = await this.sendRequest(NIP46_METHOD.GET_PUBLIC_KEY)
554 this.remotePubkey = pubkey
555 return pubkey
556 }
557
558 /**
559 * Sign an event via the bunker.
560 */
561 async signEvent(draftEvent: TDraftEvent): Promise<VerifiedEvent> {
562 const eventJson = JSON.stringify({
563 ...draftEvent,
564 pubkey: await this.getPublicKey()
565 })
566
567 const signedEventJson = await this.sendRequest(NIP46_METHOD.SIGN_EVENT, [eventJson])
568 const signedEvent = JSON.parse(signedEventJson) as VerifiedEvent
569
570 return signedEvent
571 }
572
573 /**
574 * Encrypt a message with NIP-04 via the bunker.
575 */
576 async nip04Encrypt(pubkey: string, plainText: string): Promise<string> {
577 return this.sendRequest(NIP46_METHOD.NIP04_ENCRYPT, [pubkey, plainText])
578 }
579
580 /**
581 * Decrypt a message with NIP-04 via the bunker.
582 */
583 async nip04Decrypt(pubkey: string, cipherText: string): Promise<string> {
584 return this.sendRequest(NIP46_METHOD.NIP04_DECRYPT, [pubkey, cipherText])
585 }
586
587 /**
588 * Encrypt a message with NIP-44 via the bunker.
589 */
590 async nip44Encrypt(pubkey: string, plainText: string): Promise<string> {
591 return this.sendRequest(NIP46_METHOD.NIP44_ENCRYPT, [pubkey, plainText])
592 }
593
594 /**
595 * Decrypt a message with NIP-44 via the bunker.
596 */
597 async nip44Decrypt(pubkey: string, cipherText: string): Promise<string> {
598 return this.sendRequest(NIP46_METHOD.NIP44_DECRYPT, [pubkey, cipherText])
599 }
600
601 /**
602 * Check if connected to the bunker.
603 */
604 isConnected(): boolean {
605 return this.connected
606 }
607
608 /**
609 * Disconnect from the bunker.
610 */
611 disconnect(): void {
612 if (this.ws) {
613 this.ws.close()
614 this.ws = null
615 }
616 this.connected = false
617 this.pendingRequests.forEach((pending) => {
618 clearTimeout(pending.timeout)
619 pending.reject(new Error('Disconnected'))
620 })
621 this.pendingRequests.clear()
622 }
623
624 /**
625 * Get the bunker's public key.
626 */
627 getBunkerPubkey(): string {
628 return this.bunkerPubkey
629 }
630
631 /**
632 * Get the relay URLs.
633 */
634 getRelayUrls(): string[] {
635 return this.relayUrls
636 }
637
638 /**
639 * Get the bunker URL for sharing.
640 */
641 getBunkerUrl(): string {
642 return buildBunkerUrl(this.bunkerPubkey, this.relayUrls)
643 }
644 }
645