/** * NIP-46 Bunker Signer with Cashu Token Authentication * * Implements remote signing via NIP-46 protocol with Cashu access tokens * for authorization. The signer connects to a bunker WebSocket and * requests signing operations. * * Token flow: * 1. Connect to bunker with X-Cashu-Token header * 2. Send NIP-46 requests encrypted with NIP-04 * 3. Receive signed events from bunker */ import { ISigner, TDraftEvent } from '@/types' import cashuTokenService, { TCashuToken, TokenScope } from '@/services/cashu-token.service' import * as utils from '@noble/curves/abstract/utils' import { secp256k1 } from '@noble/curves/secp256k1' import { Event, VerifiedEvent, getPublicKey as nGetPublicKey, nip04, finalizeEvent } from 'nostr-tools' // NIP-46 methods const NIP46_METHOD = { CONNECT: 'connect', GET_PUBLIC_KEY: 'get_public_key', SIGN_EVENT: 'sign_event', NIP04_ENCRYPT: 'nip04_encrypt', NIP04_DECRYPT: 'nip04_decrypt', PING: 'ping' } as const type NIP46Method = (typeof NIP46_METHOD)[keyof typeof NIP46_METHOD] // NIP-46 request format interface NIP46Request { id: string method: NIP46Method params: string[] } // NIP-46 response format interface NIP46Response { id: string result?: string error?: string } // Pending request tracker interface PendingRequest { resolve: (value: string) => void reject: (error: Error) => void timeout: ReturnType } /** * Generate a random request ID. */ function generateRequestId(): string { const bytes = crypto.getRandomValues(new Uint8Array(16)) return utils.bytesToHex(bytes) } /** * Parse a bunker URL (bunker://?relay=&secret=&cat=). */ export function parseBunkerUrl(url: string): { pubkey: string relays: string[] secret?: string catToken?: string } { if (!url.startsWith('bunker://')) { throw new Error('Invalid bunker URL: must start with bunker://') } const withoutPrefix = url.slice('bunker://'.length) const [pubkeyPart, queryPart] = withoutPrefix.split('?') if (!pubkeyPart || pubkeyPart.length !== 64) { throw new Error('Invalid bunker URL: missing or invalid pubkey') } const params = new URLSearchParams(queryPart || '') const relays = params.getAll('relay') const secret = params.get('secret') || undefined const catToken = params.get('cat') || undefined if (relays.length === 0) { throw new Error('Invalid bunker URL: no relay specified') } return { pubkey: pubkeyPart, relays, secret, catToken } } /** * Parse a nostr+connect URL (nostr+connect://?pubkey=&secret=). * This is the format that signers (like Amber) scan to connect to a client. */ export function parseNostrConnectUrl(url: string): { relay: string pubkey?: string secret?: string } { if (!url.startsWith('nostr+connect://')) { throw new Error('Invalid nostr+connect URL: must start with nostr+connect://') } const withoutPrefix = url.slice('nostr+connect://'.length) const [relayPart, queryPart] = withoutPrefix.split('?') if (!relayPart) { throw new Error('Invalid nostr+connect URL: missing relay') } const params = new URLSearchParams(queryPart || '') const pubkey = params.get('pubkey') || undefined const secret = params.get('secret') || undefined return { relay: relayPart, pubkey, secret } } /** * Build a nostr+connect URL for signers to scan. * @param relay - The relay URL (without ws:// prefix, will be added) * @param pubkey - The client's ephemeral pubkey for this session * @param secret - Optional secret for the handshake */ export function buildNostrConnectUrl(relay: string, pubkey: string, secret?: string): string { // Ensure relay URL uses the relay host without protocol let relayHost = relay .replace('wss://', '') .replace('ws://', '') .replace('https://', '') .replace('http://', '') .replace(/\/$/, '') const params = new URLSearchParams() params.set('pubkey', pubkey) if (secret) { params.set('secret', secret) } return `nostr+connect://${relayHost}?${params.toString()}` } /** * Build a bunker URL from components. */ export function buildBunkerUrl(pubkey: string, relays: string[], secret?: string): string { const params = new URLSearchParams() relays.forEach((relay) => params.append('relay', relay)) if (secret) { params.set('secret', secret) } return `bunker://${pubkey}?${params.toString()}` } export class BunkerSigner implements ISigner { private bunkerPubkey: string private relayUrls: string[] private connectionSecret?: string private localPrivkey: Uint8Array private localPubkey: string private remotePubkey: string | null = null private ws: WebSocket | null = null private pendingRequests = new Map() private connected = false private token: TCashuToken | null = null private mintUrl: string | null = null private requestTimeout = 30000 // 30 seconds // Whether we're waiting for signer to connect (reverse flow) private awaitingConnection = false private connectionResolve: ((pubkey: string) => void) | null = null /** * Create a BunkerSigner. * @param bunkerPubkey - The bunker's public key (hex) * @param relayUrls - Relay URLs to connect to * @param connectionSecret - Optional connection secret for initial handshake * @param catToken - Optional CAT token (encoded string) for authorization */ constructor(bunkerPubkey: string, relayUrls: string[], connectionSecret?: string, catToken?: string) { this.bunkerPubkey = bunkerPubkey this.relayUrls = relayUrls this.connectionSecret = connectionSecret // Decode CAT token if provided if (catToken) { try { this.token = cashuTokenService.decodeToken(catToken) } catch (err) { console.warn('Failed to decode CAT token from URL:', err) } } // Generate local ephemeral keypair for NIP-46 communication this.localPrivkey = secp256k1.utils.randomPrivateKey() this.localPubkey = nGetPublicKey(this.localPrivkey) } /** * Create a BunkerSigner that waits for a signer (like Amber) to connect. * Returns the nostr+connect URL to display as QR code and a promise for the connected signer. * * @param relayUrl - The relay URL for the connection * @param secret - Optional secret for the handshake * @param timeout - Connection timeout in ms (default 120000 = 2 minutes) */ static async awaitSignerConnection( relayUrl: string, secret?: string, timeout = 120000 ): Promise<{ connectUrl: string; signer: Promise }> { // Generate ephemeral keypair for this session const localPrivkey = secp256k1.utils.randomPrivateKey() const localPubkey = nGetPublicKey(localPrivkey) // Generate secret if not provided const connectionSecret = secret || generateRequestId() // Build the nostr+connect URL for signer to scan const connectUrl = buildNostrConnectUrl(relayUrl, localPubkey, connectionSecret) // Create signer instance (bunkerPubkey will be set when signer connects) const signer = new BunkerSigner('', [relayUrl], connectionSecret) signer.localPrivkey = localPrivkey signer.localPubkey = localPubkey signer.awaitingConnection = true // Return URL immediately, signer promise resolves when connected const signerPromise = new Promise((resolve, reject) => { signer.connectionResolve = (signerPubkey: string) => { signer.bunkerPubkey = signerPubkey signer.remotePubkey = signerPubkey signer.awaitingConnection = false resolve(signer) } // Set timeout setTimeout(() => { if (signer.awaitingConnection) { signer.disconnect() reject(new Error('Connection timeout waiting for signer')) } }, timeout) // Connect to relay and wait signer.connectAndWait(relayUrl).catch(reject) }) return { connectUrl, signer: signerPromise } } /** * Connect to relay and wait for signer to initiate connection. */ private async connectAndWait(relayUrl: string): Promise { await this.acquireTokenIfNeeded(relayUrl) await this.connectToRelayAndListen(relayUrl) } /** * Connect to relay and listen for incoming connect requests. */ private async connectToRelayAndListen(relayUrl: string): Promise { return new Promise((resolve, reject) => { let wsUrl = relayUrl if (relayUrl.startsWith('http://')) { wsUrl = 'ws://' + relayUrl.slice(7) } else if (relayUrl.startsWith('https://')) { wsUrl = 'wss://' + relayUrl.slice(8) } else if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) { wsUrl = 'wss://' + relayUrl } // Add token if available if (this.token) { const tokenEncoded = cashuTokenService.encodeToken(this.token) const url = new URL(wsUrl) url.searchParams.set('token', tokenEncoded) wsUrl = url.toString() } const ws = new WebSocket(wsUrl) const timeout = setTimeout(() => { ws.close() reject(new Error('Connection timeout')) }, 10000) ws.onopen = () => { clearTimeout(timeout) this.ws = ws this.connected = true // Subscribe to events for our local pubkey const subId = generateRequestId() ws.send( JSON.stringify([ 'REQ', subId, { kinds: [24133], '#p': [this.localPubkey], since: Math.floor(Date.now() / 1000) - 60 } ]) ) resolve() } ws.onerror = () => { clearTimeout(timeout) reject(new Error('WebSocket error')) } ws.onclose = () => { this.connected = false this.ws = null } ws.onmessage = (event) => { this.handleMessage(event.data) } }) } /** * Get the local public key (for displaying in nostr+connect URL). */ getLocalPubkey(): string { return this.localPubkey } /** * Set the Cashu token for authentication. */ setToken(token: TCashuToken) { this.token = token } /** * Set the mint URL for token refresh. */ setMintUrl(url: string) { this.mintUrl = url cashuTokenService.setMint(url) } /** * Initialize connection to the bunker. */ async init(): Promise { // Check for stored token const stored = cashuTokenService.loadTokens(this.bunkerPubkey) if (stored?.current && !cashuTokenService.needsRefresh(stored.current)) { this.token = stored.current } // Try to acquire token for each relay if we don't have one if (!this.token) { for (const relayUrl of this.relayUrls) { try { await this.acquireTokenIfNeeded(relayUrl) if (this.token) break } catch (err) { console.warn(`Failed to acquire token for ${relayUrl}:`, err) } } } // Connect to first available relay for (const relayUrl of this.relayUrls) { try { await this.connectToRelay(relayUrl) break } catch (err) { console.warn(`Failed to connect to ${relayUrl}:`, err) } } if (!this.connected) { throw new Error('Failed to connect to any bunker relay') } // Perform NIP-46 connect handshake await this.connect() } /** * Check if relay requires Cashu token and acquire one if needed. */ private async acquireTokenIfNeeded(relayUrl: string): Promise { // Convert to HTTP URL for mint endpoints let mintUrl = relayUrl if (relayUrl.startsWith('ws://')) { mintUrl = 'http://' + relayUrl.slice(5) } else if (relayUrl.startsWith('wss://')) { mintUrl = 'https://' + relayUrl.slice(6) } else if (!relayUrl.startsWith('http://') && !relayUrl.startsWith('https://')) { mintUrl = 'https://' + relayUrl } mintUrl = mintUrl.replace(/\/$/, '') try { // Check if relay has Cashu mint endpoints const infoResponse = await fetch(`${mintUrl}/cashu/info`) if (!infoResponse.ok) { console.log(`Relay ${relayUrl} does not support Cashu tokens`) return } await infoResponse.json() // Validate JSON response console.log(`Relay ${relayUrl} requires Cashu token, acquiring...`) // Configure the mint this.mintUrl = mintUrl cashuTokenService.setMint(mintUrl) // Create NIP-98 auth signer using our local ephemeral key const signHttpAuth = async (url: string, method: string): Promise => { const authEvent: TDraftEvent = { kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [ ['u', url], ['method', method] ] } const signedAuth = finalizeEvent(authEvent, this.localPrivkey) // Encode as base64url for NIP-98 header const eventJson = JSON.stringify(signedAuth) const base64 = btoa(eventJson) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') return `Nostr ${base64}` } // Request token with NIP-46 scope const token = await cashuTokenService.requestToken( TokenScope.NIP46, utils.hexToBytes(this.localPubkey), signHttpAuth, [24133] // NIP-46 kind ) this.token = token cashuTokenService.storeTokens(this.bunkerPubkey, token) console.log(`Acquired Cashu token for ${relayUrl}, expires: ${new Date(token.expiry * 1000).toISOString()}`) } catch (err) { // Relay doesn't support Cashu or request failed - continue without token console.warn(`Could not acquire Cashu token for ${relayUrl}:`, err) } } /** * Connect to a relay WebSocket. */ private async connectToRelay(relayUrl: string): Promise { return new Promise((resolve, reject) => { // Convert ws:// or wss:// URL let wsUrl = relayUrl if (relayUrl.startsWith('http://')) { wsUrl = 'ws://' + relayUrl.slice(7) } else if (relayUrl.startsWith('https://')) { wsUrl = 'wss://' + relayUrl.slice(8) } else if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) { wsUrl = 'wss://' + relayUrl } // Add Cashu token header if available // Note: WebSocket API doesn't support custom headers directly, // so we'll need to pass token as a subprotocol or query param if (this.token) { const tokenEncoded = cashuTokenService.encodeToken(this.token) const url = new URL(wsUrl) url.searchParams.set('token', tokenEncoded) wsUrl = url.toString() } const ws = new WebSocket(wsUrl) const timeout = setTimeout(() => { ws.close() reject(new Error('Connection timeout')) }, 10000) ws.onopen = () => { clearTimeout(timeout) this.ws = ws this.connected = true // Subscribe to responses for our local pubkey const subId = generateRequestId() ws.send( JSON.stringify([ 'REQ', subId, { kinds: [24133], // NIP-46 response kind '#p': [this.localPubkey], since: Math.floor(Date.now() / 1000) - 60 } ]) ) resolve() } ws.onerror = () => { clearTimeout(timeout) reject(new Error('WebSocket error')) } ws.onclose = () => { this.connected = false this.ws = null } ws.onmessage = (event) => { this.handleMessage(event.data) } }) } /** * Handle incoming WebSocket messages. */ private async handleMessage(data: string): Promise { try { const msg = JSON.parse(data) if (!Array.isArray(msg)) return const [type, ...rest] = msg if (type === 'EVENT') { const [, event] = rest as [string, Event] if (event.kind === 24133) { await this.handleNIP46Response(event) } } else if (type === 'OK') { // Event published confirmation } else if (type === 'NOTICE') { console.warn('Relay notice:', rest[0]) } } catch (err) { console.error('Failed to parse message:', err) } } /** * Handle NIP-46 response event. */ private async handleNIP46Response(event: Event): Promise { try { // Decrypt the content with NIP-04 const decrypted = await nip04.decrypt(this.localPrivkey, event.pubkey, event.content) const parsed = JSON.parse(decrypted) // Check if this is an incoming connect request (signer initiating connection) if (this.awaitingConnection && parsed.method === 'connect') { const request = parsed as NIP46Request console.log('Received connect request from signer:', event.pubkey) // Verify secret if we have one if (this.connectionSecret) { const providedSecret = request.params[1] // Second param is the secret if (providedSecret !== this.connectionSecret) { console.warn('Connect request has wrong secret, ignoring') return } } // Send ack response const response: NIP46Response = { id: request.id, result: 'ack' } const encrypted = await nip04.encrypt(this.localPrivkey, event.pubkey, JSON.stringify(response)) const responseEvent: TDraftEvent = { kind: 24133, created_at: Math.floor(Date.now() / 1000), content: encrypted, tags: [['p', event.pubkey]] } const signedResponse = finalizeEvent(responseEvent, this.localPrivkey) this.ws?.send(JSON.stringify(['EVENT', signedResponse])) // Resolve the connection promise if (this.connectionResolve) { this.connectionResolve(event.pubkey) } return } // Handle as normal response const response = parsed as NIP46Response const pending = this.pendingRequests.get(response.id) if (pending) { clearTimeout(pending.timeout) this.pendingRequests.delete(response.id) if (response.error) { pending.reject(new Error(response.error)) } else if (response.result !== undefined) { pending.resolve(response.result) } else { pending.reject(new Error('Empty response')) } } } catch (err) { console.error('Failed to handle NIP-46 response:', err) } } /** * Send a NIP-46 request and wait for response. */ private async sendRequest(method: NIP46Method, params: string[] = []): Promise { if (!this.ws || !this.connected) { throw new Error('Not connected to bunker') } const request: NIP46Request = { id: generateRequestId(), method, params } // Encrypt with NIP-04 to the bunker's pubkey const encrypted = await nip04.encrypt(this.localPrivkey, this.bunkerPubkey, JSON.stringify(request)) // Create NIP-46 request event const draftEvent: TDraftEvent = { kind: 24133, created_at: Math.floor(Date.now() / 1000), content: encrypted, tags: [['p', this.bunkerPubkey]] } const signedEvent = finalizeEvent(draftEvent, this.localPrivkey) // Send to relay this.ws.send(JSON.stringify(['EVENT', signedEvent])) // Wait for response return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pendingRequests.delete(request.id) reject(new Error('Request timeout')) }, this.requestTimeout) this.pendingRequests.set(request.id, { resolve, reject, timeout }) }) } /** * Perform NIP-46 connect handshake. */ private async connect(): Promise { const params: string[] = [this.localPubkey] if (this.connectionSecret) { params.push(this.connectionSecret) } const result = await this.sendRequest(NIP46_METHOD.CONNECT, params) if (result !== 'ack') { throw new Error(`Connect failed: ${result}`) } } /** * Get the public key of the user (from the bunker). */ async getPublicKey(): Promise { if (this.remotePubkey) { return this.remotePubkey } const pubkey = await this.sendRequest(NIP46_METHOD.GET_PUBLIC_KEY) this.remotePubkey = pubkey return pubkey } /** * Sign an event via the bunker. */ async signEvent(draftEvent: TDraftEvent): Promise { const eventJson = JSON.stringify({ ...draftEvent, pubkey: await this.getPublicKey() }) const signedEventJson = await this.sendRequest(NIP46_METHOD.SIGN_EVENT, [eventJson]) const signedEvent = JSON.parse(signedEventJson) as VerifiedEvent return signedEvent } /** * Encrypt a message with NIP-04 via the bunker. */ async nip04Encrypt(pubkey: string, plainText: string): Promise { return this.sendRequest(NIP46_METHOD.NIP04_ENCRYPT, [pubkey, plainText]) } /** * Decrypt a message with NIP-04 via the bunker. */ async nip04Decrypt(pubkey: string, cipherText: string): Promise { return this.sendRequest(NIP46_METHOD.NIP04_DECRYPT, [pubkey, cipherText]) } /** * Check if connected to the bunker. */ isConnected(): boolean { return this.connected } /** * Get the current token. */ getToken(): TCashuToken | null { return this.token } /** * Request a new token from the mint. * Requires a signing function for NIP-98 auth. */ async refreshToken( signHttpAuth: (url: string, method: string) => Promise, userPubkey: Uint8Array ): Promise { if (!this.mintUrl) { throw new Error('Mint URL not configured') } const token = await cashuTokenService.requestToken( TokenScope.NIP46, userPubkey, signHttpAuth, [24133] // NIP-46 kind ) this.token = token // Store the new token const existing = cashuTokenService.loadTokens(this.bunkerPubkey) if (existing?.current && cashuTokenService.verifyToken(existing.current)) { // Current still valid, store new as next cashuTokenService.storeTokens(this.bunkerPubkey, existing.current, token) } else { // Current expired or invalid, use new as current cashuTokenService.storeTokens(this.bunkerPubkey, token) } return token } /** * Disconnect from the bunker. */ disconnect(): void { if (this.ws) { this.ws.close() this.ws = null } this.connected = false this.pendingRequests.forEach((pending) => { clearTimeout(pending.timeout) pending.reject(new Error('Disconnected')) }) this.pendingRequests.clear() } /** * Get the bunker's public key. */ getBunkerPubkey(): string { return this.bunkerPubkey } /** * Get the relay URLs. */ getRelayUrls(): string[] { return this.relayUrls } /** * Get the bunker URL for sharing. */ getBunkerUrl(): string { return buildBunkerUrl(this.bunkerPubkey, this.relayUrls) } }