Add nostr+connect QR code flow for bunker login (v0.2.1)
- Add buildNostrConnectUrl() and parseNostrConnectUrl() for nostr+connect:// URLs - Add BunkerSigner.awaitSignerConnection() for reverse flow (client waits for signer) - Update BunkerLogin to show QR code that signers like Amber can scan - Add bunkerLoginWithSigner() to NostrProvider for completing login after scan - Support both QR scan mode and paste bunker URL mode 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -92,6 +92,60 @@ export function parseBunkerUrl(url: string): {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a nostr+connect URL (nostr+connect://<relay-url>?pubkey=<client-pubkey>&secret=<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.
|
||||
*/
|
||||
@@ -118,6 +172,11 @@ export class BunkerSigner implements ISigner {
|
||||
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
|
||||
private connectionReject: ((error: Error) => void) | null = null
|
||||
|
||||
/**
|
||||
* Create a BunkerSigner.
|
||||
* @param bunkerPubkey - The bunker's public key (hex)
|
||||
@@ -134,6 +193,142 @@ export class BunkerSigner implements ISigner {
|
||||
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<BunkerSigner> }> {
|
||||
// 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<BunkerSigner>((resolve, reject) => {
|
||||
signer.connectionResolve = (signerPubkey: string) => {
|
||||
signer.bunkerPubkey = signerPubkey
|
||||
signer.remotePubkey = signerPubkey
|
||||
signer.awaitingConnection = false
|
||||
resolve(signer)
|
||||
}
|
||||
signer.connectionReject = reject
|
||||
|
||||
// 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<void> {
|
||||
await this.acquireTokenIfNeeded(relayUrl)
|
||||
await this.connectToRelayAndListen(relayUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to relay and listen for incoming connect requests.
|
||||
*/
|
||||
private async connectToRelayAndListen(relayUrl: string): Promise<void> {
|
||||
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.
|
||||
*/
|
||||
@@ -359,8 +554,46 @@ export class BunkerSigner implements ISigner {
|
||||
try {
|
||||
// Decrypt the content with NIP-04
|
||||
const decrypted = await nip04.decrypt(this.localPrivkey, event.pubkey, event.content)
|
||||
const parsed = JSON.parse(decrypted)
|
||||
|
||||
const response: NIP46Response = 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) {
|
||||
|
||||
Reference in New Issue
Block a user