Files
smesh/src/providers/NostrProvider/bunker.signer.ts
woikos cdfd034c68 Support CAT token in bunker URLs for NIP-46 connections (v0.2.2)
- Add 'bunker' to SignerType with isRemote getter and displayName
- Parse CAT token from bunker URL (?cat= parameter)
- Pass CAT token to BunkerSigner constructor
- Store bunkerCatToken in account for reconnection
- Add deploy command documentation

Files modified:
- src/domain/identity/SignerType.ts: Add bunker signer type
- src/providers/NostrProvider/bunker.signer.ts: Parse and use CAT tokens
- src/providers/NostrProvider/index.tsx: Pass CAT to login/reconnect
- src/types/index.d.ts: Add bunkerCatToken to TAccount

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 13:02:34 +01:00

809 lines
23 KiB
TypeScript

/**
* 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>&cat=<token>).
*/
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://<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.
*/
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
// 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<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)
}
// 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.
*/
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
}
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<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 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<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)
}
}