From ebe7df7b9ed047ec9b362cf7f9c746ff0313354f Mon Sep 17 00:00:00 2001 From: hoppe <41467626+tajava2006@users.noreply.github.com> Date: Mon, 25 Aug 2025 03:53:01 +0900 Subject: [PATCH] feat(nip46): Add support for client-initiated connections in BunkerSigner (#502) * add: nostrconnect * fix: typo --- README.md | 59 ++++++++++++++-- nip46.ts | 207 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 249 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index e2867b1..c9af0cd 100644 --- a/README.md +++ b/README.md @@ -179,14 +179,24 @@ for (let block of nip27.parse(evt.content)) { ### Connecting to a bunker using NIP-46 +`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. + +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. + +```js +import { generateSecretKey } from '@nostr/tools/pure' + +const localSecretKey = generateSecretKey() +``` + +### Method 1: Using a Bunker URI (`bunker://`) + +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. + ```js -import { generateSecretKey, getPublicKey } from '@nostr/tools/pure' import { BunkerSigner, parseBunkerInput } from '@nostr/tools/nip46' import { SimplePool } from '@nostr/tools/pool' -// the client needs a local secret key (which is generally persisted) for communicating with the bunker -const localSecretKey = generateSecretKey() - // parse a bunker URI const bunkerPointer = await parseBunkerInput('bunker://abcd...?relay=wss://relay.example.com') if (!bunkerPointer) { @@ -195,7 +205,7 @@ if (!bunkerPointer) { // create the bunker instance const pool = new SimplePool() -const bunker = new BunkerSigner(localSecretKey, bunkerPointer, { pool }) +const bunker = BunkerSigner.fromBunker(localSecretKey, bunkerPointer, { pool }) await bunker.connect() // and use it @@ -212,6 +222,45 @@ await signer.close() pool.close([]) ``` +### Method 2: Using a Client-generated URI (`nostrconnect://`) + +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. + +`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. + +```js +import { getPublicKey } from '@nostr/tools/pure' +import { BunkerSigner, createNostrConnectURI } from '@nostr/tools/nip46' +import { SimplePool } from '@nostr/tools/pool' + +const clientPubkey = getPublicKey(localSecretKey) + +// generate a connection URI for the bunker to scan +const connectionUri = createNostrConnectURI({ + clientPubkey, + relays: ['wss://relay.damus.io', 'wss://relay.primal.net'], + secret: 'a-random-secret-string', // A secret to verify the bunker's response + name: 'My Awesome App' +}) + +// wait for the bunker to connect +const pool = new SimplePool() +const signer = await BunkerSigner.fromURI(localSecretKey, connectionUri, { pool }) + +// and use it +const pubkey = await signer.getPublicKey() +const event = await signer.signEvent({ + kind: 1, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: 'Hello from a client-initiated connection!' +}) + +// cleanup +await signer.close() +pool.close([]) +``` + ### Parsing thread from any note based on NIP-10 ```js diff --git a/nip46.ts b/nip46.ts index c4102f1..7c05dd1 100644 --- a/nip46.ts +++ b/nip46.ts @@ -77,6 +77,115 @@ export async function queryBunkerProfile(nip05: string): Promise { + queryParams.append('relay', relay) + }) + + queryParams.append('secret', params.secret) + + if (params.perms && params.perms.length > 0) { + queryParams.append('perms', params.perms.join(',')) + } + if (params.name) { + queryParams.append('name', params.name) + } + if (params.url) { + queryParams.append('url', params.url) + } + if (params.image) { + queryParams.append('image', params.image) + } + + return `nostrconnect://${params.clientPubkey}?${queryParams.toString()}` +} + +export function parseNostrConnectURI(uri: string): ParsedNostrConnectURI { + if (!uri.startsWith('nostrconnect://')) { + throw new Error('Invalid nostrconnect URI: Must start with "nostrconnect://".') + } + + const [protocolAndPubkey, queryString] = uri.split('?') + if (!protocolAndPubkey || !queryString) { + throw new Error('Invalid nostrconnect URI: Missing query string.') + } + + const clientPubkey = protocolAndPubkey.substring('nostrconnect://'.length) + if (!clientPubkey) { + throw new Error('Invalid nostrconnect URI: Missing client-pubkey.') + } + + const queryParams = new URLSearchParams(queryString) + + const relays = queryParams.getAll('relay') + if (relays.length === 0) { + throw new Error('Invalid nostrconnect URI: Missing "relay" parameter.') + } + + const secret = queryParams.get('secret') + if (!secret) { + throw new Error('Invalid nostrconnect URI: Missing "secret" parameter.') + } + + const permsString = queryParams.get('perms') + const perms = permsString ? permsString.split(',') : undefined + + const name = queryParams.get('name') || undefined + const url = queryParams.get('url') || undefined + const image = queryParams.get('image') || undefined + + return { + protocol: 'nostrconnect', + clientPubkey, + params: { + relays, + secret, + perms, + name, + url, + image, + }, + originalString: uri, + } +} + + export type BunkerSignerParams = { pool?: AbstractSimplePool onauth?: (url: string) => void @@ -97,8 +206,9 @@ export class BunkerSigner implements Signer { } private waitingForAuth: { [id: string]: boolean } private secretKey: Uint8Array - private conversationKey: Uint8Array - public bp: BunkerPointer + // If the client initiates the connection, the two variables below can be filled in later. + private conversationKey!: Uint8Array + public bp!: BunkerPointer private cachedPubKey: string | undefined @@ -108,25 +218,98 @@ export class BunkerSigner implements Signer { * @param remotePubkey - An optional remote public key. This is the key you want to sign as. * @param secretKey - An optional key pair. */ - public constructor(clientSecretKey: Uint8Array, bp: BunkerPointer, params: BunkerSignerParams = {}) { - if (bp.relays.length === 0) { - throw new Error('no relays are specified for this bunker') - } - + private constructor(clientSecretKey: Uint8Array, params: BunkerSignerParams) { this.params = params this.pool = params.pool || new SimplePool() this.secretKey = clientSecretKey - this.conversationKey = getConversationKey(clientSecretKey, bp.pubkey) - this.bp = bp this.isOpen = false this.idPrefix = Math.random().toString(36).substring(7) this.serial = 0 this.listeners = {} this.waitingForAuth = {} - - this.setupSubscription(params) } + /** + * [Factory Method 1] Creates a Signer using bunker information (bunker:// URL or NIP-05). + * This method is used when the public key of the bunker is known in advance. + */ + public static fromBunker( + clientSecretKey: Uint8Array, + bp: BunkerPointer, + params: BunkerSignerParams = {} + ): BunkerSigner { + if (bp.relays.length === 0) { + throw new Error('No relays specified for this bunker') + } + + const signer = new BunkerSigner(clientSecretKey, params) + + signer.conversationKey = getConversationKey(clientSecretKey, bp.pubkey) + signer.bp = bp + + signer.setupSubscription(params) + return signer + } + + /** + * [Factory Method 2] Creates a Signer using a nostrconnect:// URI generated by the client. + * In this method, the bunker initiates the connection by scanning the URI. + */ + public static async fromURI( + clientSecretKey: Uint8Array, + connectionURI: string, + params: BunkerSignerParams = {}, + maxWait: number = 300_000, + ): Promise { + const signer = new BunkerSigner(clientSecretKey, params) + const parsedURI = parseNostrConnectURI(connectionURI) + const clientPubkey = getPublicKey(clientSecretKey) + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + sub.close() + reject(new Error(`Connection timed out after ${maxWait / 1000} seconds`)) + }, maxWait) + + const sub = signer.pool.subscribe( + parsedURI.params.relays, + { kinds: [NostrConnect], '#p': [clientPubkey] }, + { + onevent: async (event: NostrEvent) => { + try { + const tempConvKey = getConversationKey(clientSecretKey, event.pubkey) + const decryptedContent = decrypt(event.content, tempConvKey) + + const response = JSON.parse(decryptedContent) + + if (response.result === parsedURI.params.secret) { + clearTimeout(timer) + sub.close() + + signer.bp = { + pubkey: event.pubkey, + relays: parsedURI.params.relays, + secret: parsedURI.params.secret, + } + signer.conversationKey = getConversationKey(clientSecretKey, event.pubkey) + signer.setupSubscription(params) + resolve(signer) + } + } catch (e) { + console.warn('Failed to process potential connection event', e) + } + }, + onclose: () => { + clearTimeout(timer) + reject(new Error('Subscription closed before connection was established.')) + }, + maxWait, + } + ) + }) + } + + private setupSubscription(params: BunkerSignerParams) { const listeners = this.listeners const waitingForAuth = this.waitingForAuth @@ -290,7 +473,7 @@ export async function createAccount( ): Promise { if (email && !EMAIL_REGEX.test(email)) throw new Error('Invalid email') - let rpc = new BunkerSigner(localSecretKey, bunker.bunkerPointer, params) + let rpc = BunkerSigner.fromBunker(localSecretKey, bunker.bunkerPointer, params) let pubkey = await rpc.sendRequest('create_account', [username, domain, email || ''])