Auto-acquire Cashu tokens for NIP-46 bunker connections (v0.2.0)
- Add acquireTokenIfNeeded() in BunkerSigner to get CAT before connecting - Check /cashu/info to detect CAT-enabled relays - Request token via cashuTokenService with NIP-98 auth using ephemeral key - Store and reuse tokens across sessions - Pass token as query parameter on WebSocket connection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
565
src/providers/NostrProvider/bunker.signer.ts
Normal file
565
src/providers/NostrProvider/bunker.signer.ts
Normal file
@@ -0,0 +1,565 @@
|
||||
/**
|
||||
* 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<typeof setTimeout>
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random request ID.
|
||||
*/
|
||||
function generateRequestId(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16))
|
||||
return utils.bytesToHex(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a bunker URL (bunker://<pubkey>?relay=<url>&secret=<secret>).
|
||||
*/
|
||||
export function parseBunkerUrl(url: string): {
|
||||
pubkey: string
|
||||
relays: string[]
|
||||
secret?: 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
|
||||
|
||||
if (relays.length === 0) {
|
||||
throw new Error('Invalid bunker URL: no relay specified')
|
||||
}
|
||||
|
||||
return {
|
||||
pubkey: pubkeyPart,
|
||||
relays,
|
||||
secret
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, PendingRequest>()
|
||||
private connected = false
|
||||
private token: TCashuToken | null = null
|
||||
private mintUrl: string | null = null
|
||||
private requestTimeout = 30000 // 30 seconds
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
constructor(bunkerPubkey: string, relayUrls: string[], connectionSecret?: string) {
|
||||
this.bunkerPubkey = bunkerPubkey
|
||||
this.relayUrls = relayUrls
|
||||
this.connectionSecret = connectionSecret
|
||||
|
||||
// Generate local ephemeral keypair for NIP-46 communication
|
||||
this.localPrivkey = secp256k1.utils.randomPrivateKey()
|
||||
this.localPubkey = nGetPublicKey(this.localPrivkey)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
// 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<void> {
|
||||
// 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
|
||||
}
|
||||
|
||||
const mintInfo = await infoResponse.json()
|
||||
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<string> => {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
// Decrypt the content with NIP-04
|
||||
const decrypted = await nip04.decrypt(this.localPrivkey, event.pubkey, event.content)
|
||||
|
||||
const response: NIP46Response = JSON.parse(decrypted)
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<VerifiedEvent> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string>,
|
||||
userPubkey: Uint8Array
|
||||
): Promise<TCashuToken> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user