2 Commits

Author SHA1 Message Date
woikos
d9343a76bb v0.5.1: Remove CAT token support from NRC and NIP-46 bunker
- Delete cashu-token.service.ts and TokenDisplay component
- Simplify NRC to use only secret-based authentication
- Remove CAT token handling from bunker signer
- Clean up related types and UI elements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 06:03:17 +01:00
woikos
28b8720dbf v0.5.0: CAT token service improvements
- Improved Cashu Access Token handling
- Version bump to v0.5.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:54:18 +01:00
14 changed files with 120 additions and 1531 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "smesh", "name": "smesh",
"version": "0.4.1", "version": "0.5.1",
"description": "A user-friendly Nostr client for exploring relay feeds", "description": "A user-friendly Nostr client for exploring relay feeds",
"private": true, "private": true,
"type": "module", "type": "module",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -51,8 +51,7 @@ import {
Smartphone, Smartphone,
Download, Download,
Camera, Camera,
Zap, Zap
Coins
} from 'lucide-react' } from 'lucide-react'
import { NRCConnection, RemoteConnection } from '@/services/nrc' import { NRCConnection, RemoteConnection } from '@/services/nrc'
import QRCode from 'qrcode' import QRCode from 'qrcode'
@@ -68,7 +67,6 @@ export default function NRCSettings() {
connections, connections,
activeSessions, activeSessions,
rendezvousUrl, rendezvousUrl,
relaySupportsCat,
enable, enable,
disable, disable,
addConnection, addConnection,
@@ -415,23 +413,6 @@ export default function NRCSettings() {
)} )}
</div> </div>
{/* CAT (Cashu Access Token) Status */}
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
<div className="flex items-center gap-2">
<Coins className="w-4 h-4" />
<span className="text-sm">{t('CAT Authentication')}</span>
</div>
{relaySupportsCat ? (
<span className="px-2 py-1 bg-primary/10 text-primary rounded text-xs">
{t('Available')}
</span>
) : (
<span className="px-2 py-1 bg-muted text-muted-foreground rounded text-xs">
{t('Not Available')}
</span>
)}
</div>
{/* Connections List */} {/* Connections List */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -465,11 +446,6 @@ export default function NRCSettings() {
<div className="font-medium truncate">{connection.label}</div> <div className="font-medium truncate">{connection.label}</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{new Date(connection.createdAt).toLocaleDateString()} {new Date(connection.createdAt).toLocaleDateString()}
{connection.useCat && (
<span className="ml-2 px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px]">
CAT
</span>
)}
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

View File

@@ -1,280 +0,0 @@
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import cashuTokenService, { TCashuToken, TokenScope } from '@/services/cashu-token.service'
import { useNostr } from '@/providers/NostrProvider'
import { Clock, Copy, Key, RefreshCw, Shield } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import QrCode from '../QrCode'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import * as utils from '@noble/curves/abstract/utils'
dayjs.extend(relativeTime)
interface TokenDisplayProps {
bunkerPubkey: string
mintUrl: string
}
export default function TokenDisplay({ bunkerPubkey, mintUrl }: TokenDisplayProps) {
const { t } = useTranslation()
const { signHttpAuth, pubkey } = useNostr()
const [currentToken, setCurrentToken] = useState<TCashuToken | null>(null)
const [nextToken, setNextToken] = useState<TCashuToken | null>(null)
const [loading, setLoading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
// Load tokens on mount
useEffect(() => {
const stored = cashuTokenService.loadTokens(bunkerPubkey)
if (stored) {
setCurrentToken(stored.current || null)
setNextToken(stored.next || null)
}
}, [bunkerPubkey])
// Request a new token
const requestToken = useCallback(async () => {
if (!pubkey) {
toast.error(t('You must be logged in to request a token'))
return
}
setLoading(true)
try {
cashuTokenService.setMint(mintUrl)
await cashuTokenService.fetchMintInfo()
const userPubkey = utils.hexToBytes(pubkey)
const token = await cashuTokenService.requestToken(
TokenScope.NIP46,
userPubkey,
signHttpAuth,
[24133] // NIP-46 kind
)
// Store the token
if (currentToken && cashuTokenService.verifyToken(currentToken)) {
// Current still valid, store new as next
cashuTokenService.storeTokens(bunkerPubkey, currentToken, token)
setNextToken(token)
} else {
// Current expired or missing, use new as current
cashuTokenService.storeTokens(bunkerPubkey, token)
setCurrentToken(token)
setNextToken(null)
}
toast.success(t('Token obtained successfully'))
} catch (err) {
toast.error(t('Failed to get token') + ': ' + (err as Error).message)
} finally {
setLoading(false)
}
}, [bunkerPubkey, mintUrl, pubkey, signHttpAuth, currentToken, t])
// Refresh tokens (promote next to current if needed)
const refreshTokens = useCallback(async () => {
if (!pubkey) return
setRefreshing(true)
try {
// Check if current needs refresh
if (currentToken && cashuTokenService.needsRefresh(currentToken)) {
// Request a new token as next
if (!nextToken) {
await requestToken()
}
}
// Promote next to current if current expired
const now = Date.now() / 1000
if (currentToken && currentToken.expiry <= now && nextToken) {
cashuTokenService.storeTokens(bunkerPubkey, nextToken)
setCurrentToken(nextToken)
setNextToken(null)
toast.info(t('Token rotated'))
}
} finally {
setRefreshing(false)
}
}, [bunkerPubkey, currentToken, nextToken, pubkey, requestToken, t])
// Copy token to clipboard
const copyToken = useCallback(
(token: TCashuToken) => {
const encoded = cashuTokenService.encodeToken(token)
navigator.clipboard.writeText(encoded)
toast.success(t('Token copied to clipboard'))
},
[t]
)
// Format expiry time
const formatExpiry = (expiry: number) => {
const date = dayjs.unix(expiry)
const now = dayjs()
if (date.isBefore(now)) {
return t('Expired')
}
return date.fromNow()
}
// Check if token is expired
const isExpired = (token: TCashuToken) => {
return token.expiry < Date.now() / 1000
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
{t('Access Tokens')}
</CardTitle>
<CardDescription>
{t('Cashu tokens for authenticated bunker access')}
</CardDescription>
</CardHeader>
<CardContent>
{!currentToken && !nextToken ? (
<div className="text-center py-8">
<Key className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4">
{t('No tokens available. Request one to enable bunker access.')}
</p>
<Button onClick={requestToken} disabled={loading}>
{loading ? t('Requesting...') : t('Request Token')}
</Button>
</div>
) : (
<Tabs defaultValue="current" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="current" className="relative">
{t('Current')}
{currentToken && isExpired(currentToken) && (
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-destructive" />
)}
</TabsTrigger>
<TabsTrigger value="next">
{t('Next')}
{nextToken && (
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-green-500" />
)}
</TabsTrigger>
</TabsList>
<TabsContent value="current" className="space-y-4">
{currentToken ? (
<TokenCard
token={currentToken}
formatExpiry={formatExpiry}
isExpired={isExpired(currentToken)}
onCopy={() => copyToken(currentToken)}
/>
) : (
<div className="text-center py-4 text-muted-foreground">
{t('No current token')}
</div>
)}
</TabsContent>
<TabsContent value="next" className="space-y-4">
{nextToken ? (
<TokenCard
token={nextToken}
formatExpiry={formatExpiry}
isExpired={isExpired(nextToken)}
onCopy={() => copyToken(nextToken)}
/>
) : (
<div className="text-center py-4 text-muted-foreground">
{t('No pending token. One will be requested before current expires.')}
</div>
)}
</TabsContent>
</Tabs>
)}
</CardContent>
<CardFooter className="flex gap-2">
<Button variant="outline" onClick={refreshTokens} disabled={refreshing} className="flex-1">
<RefreshCw className={`h-4 w-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
{t('Refresh')}
</Button>
<Button onClick={requestToken} disabled={loading} className="flex-1">
{loading ? t('Requesting...') : t('Request New Token')}
</Button>
</CardFooter>
</Card>
)
}
// Individual token display card
function TokenCard({
token,
formatExpiry,
isExpired,
onCopy
}: {
token: TCashuToken
formatExpiry: (expiry: number) => string
isExpired: boolean
onCopy: () => void
}) {
const { t } = useTranslation()
const encoded = cashuTokenService.encodeToken(token)
return (
<div className="space-y-4">
<div className="flex justify-center">
<QrCode value={encoded} size={200} />
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('Scope')}</span>
<span className="font-mono">{token.scope}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('Keyset')}</span>
<span className="font-mono text-xs">{token.keysetId}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{t('Expires')}
</span>
<span className={isExpired ? 'text-destructive' : 'text-green-600'}>
{formatExpiry(token.expiry)}
</span>
</div>
{token.kinds && token.kinds.length > 0 && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('Kinds')}</span>
<span className="font-mono text-xs">{token.kinds.join(', ')}</span>
</div>
)}
</div>
<Button variant="outline" onClick={onCopy} className="w-full">
<Copy className="h-4 w-4 mr-2" />
{t('Copy Token')}
</Button>
</div>
)
}

View File

@@ -8,23 +8,17 @@
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react' import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'
import { Filter, Event } from 'nostr-tools' import { Filter, Event } from 'nostr-tools'
import * as utils from '@noble/curves/abstract/utils'
import { useNostr } from './NostrProvider' import { useNostr } from './NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import cashuTokenService, { TokenScope } from '@/services/cashu-token.service'
import { import {
NRCConnection, NRCConnection,
CATConfig,
NRCListenerConfig, NRCListenerConfig,
generateConnectionURI, generateConnectionURI,
generateCATConnectionURI,
getNRCListenerService, getNRCListenerService,
syncFromRemote, syncFromRemote,
testConnection, testConnection,
parseConnectionURI, parseConnectionURI,
relaySupportsCat,
deriveMintUrlFromRelay,
requestRemoteIDs, requestRemoteIDs,
sendEventsToRemote, sendEventsToRemote,
EventManifestEntry EventManifestEntry
@@ -50,7 +44,6 @@ interface NRCContextType {
isConnected: boolean isConnected: boolean
connections: NRCConnection[] // Devices authorized to connect to us connections: NRCConnection[] // Devices authorized to connect to us
activeSessions: number activeSessions: number
relaySupportsCat: boolean // Auto-detected CAT support
rendezvousUrl: string rendezvousUrl: string
// Client State (this device connects to others) // Client State (this device connects to others)
@@ -61,7 +54,7 @@ interface NRCContextType {
// Listener Actions // Listener Actions
enable: () => Promise<void> enable: () => Promise<void>
disable: () => void disable: () => void
addConnection: (label: string, useCat?: boolean) => Promise<{ uri: string; connection: NRCConnection }> addConnection: (label: string) => Promise<{ uri: string; connection: NRCConnection }>
removeConnection: (id: string) => Promise<void> removeConnection: (id: string) => Promise<void>
getConnectionURI: (connection: NRCConnection) => string getConnectionURI: (connection: NRCConnection) => string
setRendezvousUrl: (url: string) => void setRendezvousUrl: (url: string) => void
@@ -113,9 +106,6 @@ export function NRCProvider({ children }: NRCProviderProps) {
return localStorage.getItem(STORAGE_KEY_RENDEZVOUS_URL) || DEFAULT_RENDEZVOUS_URL return localStorage.getItem(STORAGE_KEY_RENDEZVOUS_URL) || DEFAULT_RENDEZVOUS_URL
}) })
// Auto-detected CAT support for the rendezvous relay
const [relaySupportsCatState, setRelaySupportsCatState] = useState(false)
const [isListening, setIsListening] = useState(false) const [isListening, setIsListening] = useState(false)
const [isConnected, setIsConnected] = useState(false) const [isConnected, setIsConnected] = useState(false)
const [activeSessions, setActiveSessions] = useState(0) const [activeSessions, setActiveSessions] = useState(0)
@@ -155,32 +145,6 @@ export function NRCProvider({ children }: NRCProviderProps) {
localStorage.setItem(STORAGE_KEY_RENDEZVOUS_URL, rendezvousUrl) localStorage.setItem(STORAGE_KEY_RENDEZVOUS_URL, rendezvousUrl)
}, [rendezvousUrl]) }, [rendezvousUrl])
// Auto-detect CAT support when rendezvous URL changes
useEffect(() => {
let cancelled = false
const checkCatSupport = async () => {
try {
const supported = await relaySupportsCat(rendezvousUrl)
if (!cancelled) {
setRelaySupportsCatState(supported)
console.log(`[NRC] Relay ${rendezvousUrl} CAT support:`, supported)
}
} catch (err) {
if (!cancelled) {
setRelaySupportsCatState(false)
console.log(`[NRC] Failed to check CAT support for ${rendezvousUrl}:`, err)
}
}
}
checkCatSupport()
return () => {
cancelled = true
}
}, [rendezvousUrl])
// ===== Listener Logic ===== // ===== Listener Logic =====
const buildAuthorizedSecrets = useCallback((): Map<string, string> => { const buildAuthorizedSecrets = useCallback((): Map<string, string> => {
const map = new Map<string, string>() const map = new Map<string, string>()
@@ -212,16 +176,10 @@ export function NRCProvider({ children }: NRCProviderProps) {
const startListener = async () => { const startListener = async () => {
try { try {
// Build CAT config if relay supports it
const catConfig: CATConfig | undefined = relaySupportsCatState
? { mintUrl: deriveMintUrlFromRelay(rendezvousUrl), scope: 'nrc' }
: undefined
const config: NRCListenerConfig = { const config: NRCListenerConfig = {
rendezvousUrl, rendezvousUrl,
signer: client.signer!, signer: client.signer!,
authorizedSecrets: buildAuthorizedSecrets(), authorizedSecrets: buildAuthorizedSecrets()
catConfig
} }
console.log('[NRC] Starting listener with', config.authorizedSecrets.size, 'authorized clients') console.log('[NRC] Starting listener with', config.authorizedSecrets.size, 'authorized clients')
@@ -256,7 +214,7 @@ export function NRCProvider({ children }: NRCProviderProps) {
setIsConnected(false) setIsConnected(false)
setActiveSessions(0) setActiveSessions(0)
} }
}, [isEnabled, pubkey, rendezvousUrl, buildAuthorizedSecrets, relaySupportsCatState]) }, [isEnabled, pubkey, rendezvousUrl, buildAuthorizedSecrets])
useEffect(() => { useEffect(() => {
if (!isEnabled || !client.signer || !pubkey) return if (!isEnabled || !client.signer || !pubkey) return
@@ -268,40 +226,6 @@ export function NRCProvider({ children }: NRCProviderProps) {
// Minimum time between syncs for the same connection: 5 minutes // Minimum time between syncs for the same connection: 5 minutes
const MIN_SYNC_INTERVAL = 5 * 60 * 1000 const MIN_SYNC_INTERVAL = 5 * 60 * 1000
/**
* Get a CAT token for authentication
*/
const getCATToken = async (mintUrl: string, userPubkey: string): Promise<string | undefined> => {
if (!client.signer) return undefined
try {
cashuTokenService.setMint(mintUrl)
await cashuTokenService.fetchMintInfo()
const userPubkeyBytes = utils.hexToBytes(userPubkey)
const token = await cashuTokenService.requestToken(
TokenScope.NRC,
userPubkeyBytes,
async (url: string, method: string) => {
const authEvent = await client.signer!.signEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', url],
['method', method]
],
content: ''
})
return `Nostr ${btoa(JSON.stringify(authEvent))}`
}
)
return cashuTokenService.encodeToken(token)
} catch (err) {
console.error('[NRC] Failed to get CAT token:', err)
return undefined
}
}
/** /**
* Get local events for sync kinds and build manifest * Get local events for sync kinds and build manifest
*/ */
@@ -410,26 +334,11 @@ export function NRCProvider({ children }: NRCProviderProps) {
setIsSyncing(true) setIsSyncing(true)
setSyncProgress({ phase: 'connecting', eventsReceived: 0 }) setSyncProgress({ phase: 'connecting', eventsReceived: 0 })
// Get CAT token if needed
let catToken: string | undefined
if (remote.authMode === 'cat' && remote.mintUrl) {
catToken = await getCATToken(remote.mintUrl, pubkey)
if (!catToken) {
console.error(`[NRC] Failed to get CAT token for ${remote.label}, skipping`)
continue
}
}
const signer = remote.authMode === 'cat' ? client.signer : undefined
// Step 1: Get remote's event IDs // Step 1: Get remote's event IDs
setSyncProgress({ phase: 'requesting', eventsReceived: 0, message: 'Getting remote event list...' }) setSyncProgress({ phase: 'requesting', eventsReceived: 0, message: 'Getting remote event list...' })
const remoteManifest = await requestRemoteIDs( const remoteManifest = await requestRemoteIDs(
remote.uri, remote.uri,
[{ kinds: SYNC_KINDS, limit: 1000 }], [{ kinds: SYNC_KINDS, limit: 1000 }]
undefined,
signer,
catToken
) )
console.log(`[NRC] Remote has ${remoteManifest.length} events`) console.log(`[NRC] Remote has ${remoteManifest.length} events`)
@@ -444,22 +353,14 @@ export function NRCProvider({ children }: NRCProviderProps) {
let eventsSent = 0 let eventsSent = 0
let eventsReceived = 0 let eventsReceived = 0
// Step 4: Send events remote needs (need new CAT token for new connection) // Step 4: Send events remote needs
if (toSend.length > 0) { if (toSend.length > 0) {
setSyncProgress({ phase: 'sending', eventsReceived: 0, eventsSent: 0, message: `Sending ${toSend.length} events...` }) setSyncProgress({ phase: 'sending', eventsReceived: 0, eventsSent: 0, message: `Sending ${toSend.length} events...` })
// Get fresh CAT token for sending
let sendCatToken = catToken
if (remote.authMode === 'cat' && remote.mintUrl) {
sendCatToken = await getCATToken(remote.mintUrl, pubkey)
}
eventsSent = await sendEventsToRemote( eventsSent = await sendEventsToRemote(
remote.uri, remote.uri,
toSend, toSend,
(progress) => setSyncProgress({ ...progress, message: `Sending events... (${progress.eventsSent || 0}/${toSend.length})` }), (progress) => setSyncProgress({ ...progress, message: `Sending events... (${progress.eventsSent || 0}/${toSend.length})` })
signer,
sendCatToken
) )
console.log(`[NRC] Sent ${eventsSent} events to ${remote.label}`) console.log(`[NRC] Sent ${eventsSent} events to ${remote.label}`)
} }
@@ -468,12 +369,6 @@ export function NRCProvider({ children }: NRCProviderProps) {
if (toFetch.length > 0) { if (toFetch.length > 0) {
setSyncProgress({ phase: 'receiving', eventsReceived: 0, eventsSent, message: `Fetching ${toFetch.length} events...` }) setSyncProgress({ phase: 'receiving', eventsReceived: 0, eventsSent, message: `Fetching ${toFetch.length} events...` })
// Get fresh CAT token for fetching
let fetchCatToken = catToken
if (remote.authMode === 'cat' && remote.mintUrl) {
fetchCatToken = await getCATToken(remote.mintUrl, pubkey)
}
// Fetch by ID in batches (relay may limit number of IDs per filter) // Fetch by ID in batches (relay may limit number of IDs per filter)
const BATCH_SIZE = 50 const BATCH_SIZE = 50
const fetchedEvents: Event[] = [] const fetchedEvents: Event[] = []
@@ -487,16 +382,9 @@ export function NRCProvider({ children }: NRCProviderProps) {
...progress, ...progress,
eventsSent, eventsSent,
message: `Fetching events... (${fetchedEvents.length + progress.eventsReceived}/${toFetch.length})` message: `Fetching events... (${fetchedEvents.length + progress.eventsReceived}/${toFetch.length})`
}), })
signer,
fetchCatToken
) )
fetchedEvents.push(...events) fetchedEvents.push(...events)
// Get new CAT token for next batch if needed
if (remote.authMode === 'cat' && remote.mintUrl && i + BATCH_SIZE < toFetch.length) {
fetchCatToken = await getCATToken(remote.mintUrl, pubkey)
}
} }
// Store fetched events // Store fetched events
@@ -560,7 +448,7 @@ export function NRCProvider({ children }: NRCProviderProps) {
}, []) }, [])
const addConnection = useCallback( const addConnection = useCallback(
async (label: string, useCat = false): Promise<{ uri: string; connection: NRCConnection }> => { async (label: string): Promise<{ uri: string; connection: NRCConnection }> => {
if (!pubkey) { if (!pubkey) {
throw new Error('Not logged in') throw new Error('Not logged in')
} }
@@ -568,36 +456,21 @@ export function NRCProvider({ children }: NRCProviderProps) {
const id = crypto.randomUUID() const id = crypto.randomUUID()
const createdAt = Date.now() const createdAt = Date.now()
let connection: NRCConnection const result = generateConnectionURI(pubkey, rendezvousUrl, undefined, label)
let uri: string const uri = result.uri
const connection: NRCConnection = {
// Use CAT if requested AND relay supports it, otherwise fall back to secret-based id,
if (useCat && relaySupportsCatState) { label,
uri = generateCATConnectionURI(pubkey, rendezvousUrl) secret: result.secret,
connection = { clientPubkey: result.clientPubkey,
id, createdAt
label,
useCat: true,
createdAt
}
} else {
const result = generateConnectionURI(pubkey, rendezvousUrl, undefined, label)
uri = result.uri
connection = {
id,
label,
secret: result.secret,
clientPubkey: result.clientPubkey,
useCat: false,
createdAt
}
} }
setConnections((prev) => [...prev, connection]) setConnections((prev) => [...prev, connection])
return { uri, connection } return { uri, connection }
}, },
[pubkey, rendezvousUrl, relaySupportsCatState] [pubkey, rendezvousUrl]
) )
const removeConnection = useCallback(async (id: string) => { const removeConnection = useCallback(async (id: string) => {
@@ -610,23 +483,19 @@ export function NRCProvider({ children }: NRCProviderProps) {
throw new Error('Not logged in') throw new Error('Not logged in')
} }
if (connection.useCat && relaySupportsCatState) { if (!connection.secret) {
return generateCATConnectionURI(pubkey, rendezvousUrl) throw new Error('Connection has no secret')
} }
if (connection.secret) { const result = generateConnectionURI(
const result = generateConnectionURI( pubkey,
pubkey, rendezvousUrl,
rendezvousUrl, connection.secret,
connection.secret, connection.label
connection.label )
) return result.uri
return result.uri
}
throw new Error('Connection has no secret and relay does not support CAT')
}, },
[pubkey, rendezvousUrl, relaySupportsCatState] [pubkey, rendezvousUrl]
) )
const setRendezvousUrl = useCallback((url: string) => { const setRendezvousUrl = useCallback((url: string) => {
@@ -644,9 +513,7 @@ export function NRCProvider({ children }: NRCProviderProps) {
uri, uri,
label, label,
relayPubkey: parsed.relayPubkey, relayPubkey: parsed.relayPubkey,
rendezvousUrl: parsed.rendezvousUrl, rendezvousUrl: parsed.rendezvousUrl
authMode: parsed.authMode,
mintUrl: parsed.mintUrl
} }
setRemoteConnections((prev) => [...prev, remoteConnection]) setRemoteConnections((prev) => [...prev, remoteConnection])
@@ -676,47 +543,10 @@ export function NRCProvider({ children }: NRCProviderProps) {
{ kinds: [0, 3, 10000, 10001, 10002, 10003, 10012, 30002], limit: 1000 } { kinds: [0, 3, 10000, 10001, 10002, 10003, 10012, 30002], limit: 1000 }
] ]
let catToken: string | undefined
// For CAT mode, obtain a token from the mint
if (remote.authMode === 'cat' && remote.mintUrl && client.signer && pubkey) {
console.log('[NRC] CAT mode: obtaining token from mint', remote.mintUrl)
try {
cashuTokenService.setMint(remote.mintUrl)
await cashuTokenService.fetchMintInfo()
const userPubkeyBytes = utils.hexToBytes(pubkey)
const token = await cashuTokenService.requestToken(
TokenScope.NRC,
userPubkeyBytes,
async (url: string, method: string) => {
// NIP-98 HTTP auth signing
const authEvent = await client.signer!.signEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', url],
['method', method]
],
content: ''
})
return `Nostr ${btoa(JSON.stringify(authEvent))}`
}
)
catToken = cashuTokenService.encodeToken(token)
console.log('[NRC] CAT token obtained successfully')
} catch (err) {
console.error('[NRC] Failed to obtain CAT token:', err)
throw new Error(`Failed to obtain CAT token: ${err instanceof Error ? err.message : 'Unknown error'}`)
}
}
const events = await syncFromRemote( const events = await syncFromRemote(
remote.uri, remote.uri,
syncFilters, syncFilters,
(progress) => setSyncProgress(progress), (progress) => setSyncProgress(progress)
remote.authMode === 'cat' ? client.signer : undefined,
catToken
) )
// Store synced events in IndexedDB // Store synced events in IndexedDB
@@ -741,7 +571,7 @@ export function NRCProvider({ children }: NRCProviderProps) {
setSyncProgress(null) setSyncProgress(null)
} }
}, },
[remoteConnections, pubkey] [remoteConnections]
) )
const syncAllRemotes = useCallback( const syncAllRemotes = useCallback(
@@ -773,44 +603,9 @@ export function NRCProvider({ children }: NRCProviderProps) {
setSyncProgress({ phase: 'connecting', eventsReceived: 0, message: 'Testing connection...' }) setSyncProgress({ phase: 'connecting', eventsReceived: 0, message: 'Testing connection...' })
try { try {
let catToken: string | undefined
// For CAT mode, obtain a token from the mint
if (remote.authMode === 'cat' && remote.mintUrl && client.signer && pubkey) {
console.log('[NRC] CAT mode: obtaining token for test from mint', remote.mintUrl)
try {
cashuTokenService.setMint(remote.mintUrl)
await cashuTokenService.fetchMintInfo()
const userPubkeyBytes = utils.hexToBytes(pubkey)
const token = await cashuTokenService.requestToken(
TokenScope.NRC,
userPubkeyBytes,
async (url: string, method: string) => {
const authEvent = await client.signer!.signEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', url],
['method', method]
],
content: ''
})
return `Nostr ${btoa(JSON.stringify(authEvent))}`
}
)
catToken = cashuTokenService.encodeToken(token)
} catch (err) {
console.error('[NRC] Failed to obtain CAT token for test:', err)
throw new Error(`Failed to obtain CAT token: ${err instanceof Error ? err.message : 'Unknown error'}`)
}
}
const result = await testConnection( const result = await testConnection(
remote.uri, remote.uri,
(progress) => setSyncProgress(progress), (progress) => setSyncProgress(progress)
remote.authMode === 'cat' ? client.signer : undefined,
catToken
) )
// Update connection to mark it as tested // Update connection to mark it as tested
@@ -826,7 +621,7 @@ export function NRCProvider({ children }: NRCProviderProps) {
setSyncProgress(null) setSyncProgress(null)
} }
}, },
[remoteConnections, pubkey] [remoteConnections]
) )
const value: NRCContextType = { const value: NRCContextType = {
@@ -836,7 +631,6 @@ export function NRCProvider({ children }: NRCProviderProps) {
isConnected, isConnected,
connections, connections,
activeSessions, activeSessions,
relaySupportsCat: relaySupportsCatState,
rendezvousUrl, rendezvousUrl,
enable, enable,
disable, disable,

View File

@@ -1,19 +1,12 @@
/** /**
* NIP-46 Bunker Signer with Cashu Token Authentication * NIP-46 Bunker Signer
* *
* Implements remote signing via NIP-46 protocol with Cashu access tokens * Implements remote signing via NIP-46 protocol.
* for authorization. The signer connects to a bunker WebSocket and * The signer connects to a bunker WebSocket and
* requests signing operations. * 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 { ISigner, TDraftEvent } from '@/types'
import cashuTokenService, { TCashuToken, TokenScope } from '@/services/cashu-token.service'
import { relaySupportsCat, deriveMintUrlFromRelay } from '@/services/nrc'
import * as utils from '@noble/curves/abstract/utils' import * as utils from '@noble/curves/abstract/utils'
import { secp256k1 } from '@noble/curves/secp256k1' import { secp256k1 } from '@noble/curves/secp256k1'
import { Event, VerifiedEvent, getPublicKey as nGetPublicKey, nip04, finalizeEvent } from 'nostr-tools' import { Event, VerifiedEvent, getPublicKey as nGetPublicKey, nip04, finalizeEvent } from 'nostr-tools'
@@ -62,13 +55,12 @@ function generateRequestId(): string {
} }
/** /**
* Parse a bunker URL (bunker://<pubkey>?relay=<url>&secret=<secret>&cat=<token>). * Parse a bunker URL (bunker://<pubkey>?relay=<url>&secret=<secret>).
*/ */
export function parseBunkerUrl(url: string): { export function parseBunkerUrl(url: string): {
pubkey: string pubkey: string
relays: string[] relays: string[]
secret?: string secret?: string
catToken?: string
} { } {
if (!url.startsWith('bunker://')) { if (!url.startsWith('bunker://')) {
throw new Error('Invalid bunker URL: must start with bunker://') throw new Error('Invalid bunker URL: must start with bunker://')
@@ -84,7 +76,6 @@ export function parseBunkerUrl(url: string): {
const params = new URLSearchParams(queryPart || '') const params = new URLSearchParams(queryPart || '')
const relays = params.getAll('relay') const relays = params.getAll('relay')
const secret = params.get('secret') || undefined const secret = params.get('secret') || undefined
const catToken = params.get('cat') || undefined
if (relays.length === 0) { if (relays.length === 0) {
throw new Error('Invalid bunker URL: no relay specified') throw new Error('Invalid bunker URL: no relay specified')
@@ -93,8 +84,7 @@ export function parseBunkerUrl(url: string): {
return { return {
pubkey: pubkeyPart, pubkey: pubkeyPart,
relays, relays,
secret, secret
catToken
} }
} }
@@ -174,8 +164,6 @@ export class BunkerSigner implements ISigner {
private ws: WebSocket | null = null private ws: WebSocket | null = null
private pendingRequests = new Map<string, PendingRequest>() private pendingRequests = new Map<string, PendingRequest>()
private connected = false private connected = false
private token: TCashuToken | null = null
private mintUrl: string | null = null
private requestTimeout = 30000 // 30 seconds private requestTimeout = 30000 // 30 seconds
// Whether we're waiting for signer to connect (reverse flow) // Whether we're waiting for signer to connect (reverse flow)
@@ -187,22 +175,12 @@ export class BunkerSigner implements ISigner {
* @param bunkerPubkey - The bunker's public key (hex) * @param bunkerPubkey - The bunker's public key (hex)
* @param relayUrls - Relay URLs to connect to * @param relayUrls - Relay URLs to connect to
* @param connectionSecret - Optional connection secret for initial handshake * @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) { constructor(bunkerPubkey: string, relayUrls: string[], connectionSecret?: string) {
this.bunkerPubkey = bunkerPubkey this.bunkerPubkey = bunkerPubkey
this.relayUrls = relayUrls this.relayUrls = relayUrls
this.connectionSecret = connectionSecret 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 // Generate local ephemeral keypair for NIP-46 communication
this.localPrivkey = secp256k1.utils.randomPrivateKey() this.localPrivkey = secp256k1.utils.randomPrivateKey()
this.localPubkey = nGetPublicKey(this.localPrivkey) this.localPubkey = nGetPublicKey(this.localPrivkey)
@@ -264,7 +242,6 @@ export class BunkerSigner implements ISigner {
* Connect to relay and wait for signer to initiate connection. * Connect to relay and wait for signer to initiate connection.
*/ */
private async connectAndWait(relayUrl: string): Promise<void> { private async connectAndWait(relayUrl: string): Promise<void> {
await this.acquireTokenIfNeeded(relayUrl)
await this.connectToRelayAndListen(relayUrl) await this.connectToRelayAndListen(relayUrl)
} }
@@ -282,14 +259,6 @@ export class BunkerSigner implements ISigner {
wsUrl = 'wss://' + relayUrl 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 ws = new WebSocket(wsUrl)
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@@ -342,43 +311,10 @@ export class BunkerSigner implements ISigner {
return this.localPubkey 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. * Initialize connection to the bunker.
*/ */
async init(): Promise<void> { 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 // Connect to first available relay
for (const relayUrl of this.relayUrls) { for (const relayUrl of this.relayUrls) {
try { try {
@@ -397,67 +333,6 @@ export class BunkerSigner implements ISigner {
await this.connect() await this.connect()
} }
/**
* Check if relay supports Cashu tokens and acquire one if so.
* Falls back gracefully to regular connection if CAT is not supported.
*/
private async acquireTokenIfNeeded(relayUrl: string): Promise<void> {
try {
// First check if relay supports CAT using the NRC helper
const supportsCat = await relaySupportsCat(relayUrl)
if (!supportsCat) {
console.log(`[Bunker] Relay ${relayUrl} does not support CAT, using regular connection`)
return
}
console.log(`[Bunker] Relay ${relayUrl} supports CAT, acquiring token...`)
// Derive mint URL from relay URL
const mintUrl = deriveMintUrlFromRelay(relayUrl)
this.mintUrl = mintUrl
cashuTokenService.setMint(mintUrl)
// Fetch mint info to initialize the service
await cashuTokenService.fetchMintInfo()
// 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(`[Bunker] Acquired CAT token for ${relayUrl}, expires: ${new Date(token.expiry * 1000).toISOString()}`)
} catch (err) {
// Token acquisition failed - continue without token
console.log(`[Bunker] CAT token acquisition failed for ${relayUrl}, using regular connection:`, err instanceof Error ? err.message : err)
}
}
/** /**
* Connect to a relay WebSocket. * Connect to a relay WebSocket.
*/ */
@@ -473,16 +348,6 @@ export class BunkerSigner implements ISigner {
wsUrl = 'wss://' + relayUrl 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 ws = new WebSocket(wsUrl)
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@@ -636,16 +501,12 @@ export class BunkerSigner implements ISigner {
// Encrypt with NIP-04 to the bunker's pubkey // Encrypt with NIP-04 to the bunker's pubkey
const encrypted = await nip04.encrypt(this.localPrivkey, this.bunkerPubkey, JSON.stringify(request)) const encrypted = await nip04.encrypt(this.localPrivkey, this.bunkerPubkey, JSON.stringify(request))
// Create NIP-46 request event with optional CAT tag // Create NIP-46 request event
const tags: string[][] = [['p', this.bunkerPubkey]]
if (this.token) {
tags.push(['cat', cashuTokenService.encodeToken(this.token)])
}
const draftEvent: TDraftEvent = { const draftEvent: TDraftEvent = {
kind: 24133, kind: 24133,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
content: encrypted, content: encrypted,
tags tags: [['p', this.bunkerPubkey]]
} }
const signedEvent = finalizeEvent(draftEvent, this.localPrivkey) const signedEvent = finalizeEvent(draftEvent, this.localPrivkey)
@@ -742,47 +603,6 @@ export class BunkerSigner implements ISigner {
return this.connected 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 from the bunker.
*/ */

View File

@@ -491,8 +491,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const bunkerLogin = async (bunkerUrl: string) => { const bunkerLogin = async (bunkerUrl: string) => {
try { try {
const { pubkey: bunkerPubkey, relays, secret, catToken } = parseBunkerUrl(bunkerUrl) const { pubkey: bunkerPubkey, relays, secret } = parseBunkerUrl(bunkerUrl)
const bunkerSigner = new BunkerSigner(bunkerPubkey, relays, secret, catToken) const bunkerSigner = new BunkerSigner(bunkerPubkey, relays, secret)
await bunkerSigner.init() await bunkerSigner.init()
const pubkey = await bunkerSigner.getPublicKey() const pubkey = await bunkerSigner.getPublicKey()
return login(bunkerSigner, { return login(bunkerSigner, {
@@ -500,8 +500,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
signerType: 'bunker', signerType: 'bunker',
bunkerPubkey, bunkerPubkey,
bunkerRelays: relays, bunkerRelays: relays,
bunkerSecret: secret, bunkerSecret: secret
bunkerCatToken: catToken
}) })
} catch (err) { } catch (err) {
toast.error(t('Bunker login failed') + ': ' + (err as Error).message) toast.error(t('Bunker login failed') + ': ' + (err as Error).message)
@@ -578,8 +577,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const bunkerSigner = new BunkerSigner( const bunkerSigner = new BunkerSigner(
account.bunkerPubkey, account.bunkerPubkey,
account.bunkerRelays, account.bunkerRelays,
account.bunkerSecret, account.bunkerSecret
account.bunkerCatToken
) )
await bunkerSigner.init() await bunkerSigner.init()
return login(bunkerSigner, account) return login(bunkerSigner, account)

View File

@@ -1,459 +0,0 @@
/**
* Cashu Token Service
*
* Manages Cashu access tokens for bunker authentication.
* Handles token issuance, storage, and two-token rotation (current + next).
*
* Token flow:
* 1. Generate random secret and blinding factor
* 2. Compute blinded message B_ = hash_to_curve(secret) + r*G
* 3. Submit B_ to mint with NIP-98 auth
* 4. Receive blinded signature C_
* 5. Unblind: C = C_ - r*K (where K is mint's pubkey)
* 6. Store token {secret, C, keysetId, expiry, ...}
*/
import * as utils from '@noble/curves/abstract/utils'
import { secp256k1 } from '@noble/curves/secp256k1'
import { sha256 } from '@noble/hashes/sha256'
// Token scopes
export const TokenScope = {
RELAY: 'relay',
NIP46: 'nip46',
BLOSSOM: 'blossom',
API: 'api',
NRC: 'nrc'
} as const
export type TTokenScope = (typeof TokenScope)[keyof typeof TokenScope]
// Token format matching ORLY's token.Token
export type TCashuToken = {
keysetId: string // k - keyset identifier
secret: Uint8Array // s - 32-byte random secret
signature: Uint8Array // c - 33-byte signature point (compressed)
pubkey: Uint8Array // p - 32-byte user pubkey
expiry: number // e - Unix timestamp
scope: TTokenScope // sc - token scope
kinds?: number[] // kinds - permitted event kinds
kindRanges?: [number, number][] // kind_ranges - permitted ranges
}
// Keyset info from mint
export type TKeysetInfo = {
id: string
publicKey: string // hex-encoded mint public key
active: boolean
expiresAt?: number
}
// Mint info
export type TMintInfo = {
name: string
version: string
pubkey: string
keysets: TKeysetInfo[]
}
// Blinding result
type BlindResult = {
B_: Uint8Array // Blinded point (33 bytes compressed)
secret: Uint8Array // Original secret
r: Uint8Array // Blinding factor scalar
}
// Storage key prefix
const STORAGE_PREFIX = 'cashu_token_'
/**
* Hash a message to a point on secp256k1 using try-and-increment.
* Algorithm matches ORLY's Go implementation exactly:
* 1. msgHash = SHA256(domain_separator || message)
* 2. For counter in 0..65535:
* a. counterBytes = counter as 4-byte little-endian
* b. hash = SHA256(msgHash || counterBytes)
* c. compressed = 0x02 || hash
* d. If valid secp256k1 point, return compressed
*/
function hashToCurve(message: Uint8Array): Uint8Array {
const domainSeparator = new TextEncoder().encode('Secp256k1_HashToCurve_Cashu_')
const msgHash = sha256(new Uint8Array([...domainSeparator, ...message]))
// Try incrementing counter until we get a valid point
for (let counter = 0; counter < 65536; counter++) {
// 4-byte little-endian counter (matching ORLY's binary.LittleEndian.PutUint32)
const counterBytes = new Uint8Array(4)
new DataView(counterBytes.buffer).setUint32(0, counter, true) // true = little-endian
// msgHash THEN counterBytes (matching ORLY's append order)
const toHash = new Uint8Array([...msgHash, ...counterBytes])
const hash = sha256(toHash)
// Only try 0x02 prefix (even Y coordinate)
const compressed = new Uint8Array([0x02, ...hash])
try {
// Validate this is a valid point
const point = secp256k1.ProjectivePoint.fromHex(compressed)
if (!point.equals(secp256k1.ProjectivePoint.ZERO)) {
return compressed
}
} catch {
// Not a valid point, continue
}
}
throw new Error('Failed to hash to curve after 65536 attempts')
}
/**
* Create a blinded message from a secret.
* B_ = Y + r*G where Y = hash_to_curve(secret)
*/
function blind(secret: Uint8Array): BlindResult {
// Generate random blinding factor r
const r = secp256k1.utils.randomPrivateKey()
// Y = hash_to_curve(secret)
const Y = secp256k1.ProjectivePoint.fromHex(hashToCurve(secret))
// r*G
const rG = secp256k1.ProjectivePoint.BASE.multiply(utils.bytesToNumberBE(r))
// B_ = Y + r*G
const B_ = Y.add(rG)
return {
B_: B_.toRawBytes(true), // Compressed format
secret,
r
}
}
/**
* Unblind the signature to get the final signature.
* C = C_ - r*K where K is the mint's public key
*/
function unblind(C_: Uint8Array, r: Uint8Array, K: Uint8Array): Uint8Array {
const C_point = secp256k1.ProjectivePoint.fromHex(C_)
const K_point = secp256k1.ProjectivePoint.fromHex(K)
// r*K
const rK = K_point.multiply(utils.bytesToNumberBE(r))
// C = C_ - r*K
const C = C_point.subtract(rK)
return C.toRawBytes(true)
}
/**
* Verify a token signature locally.
* Checks that C = k*Y where Y = hash_to_curve(secret) and k is unknown.
* We verify using DLEQ proof or by checking C matches our expectations.
*/
function verifyToken(token: TCashuToken, _mintPubkey: Uint8Array): boolean {
try {
// Basic validation
if (token.expiry < Date.now() / 1000) {
return false
}
// Verify signature is a valid point
secp256k1.ProjectivePoint.fromHex(token.signature)
// Could implement full DLEQ verification here if needed
return true
} catch {
return false
}
}
/**
* Encode a token to the Cashu format (cashuA prefix + base64url).
*/
function encodeToken(token: TCashuToken): string {
const tokenData = {
k: token.keysetId,
s: utils.bytesToHex(token.secret),
c: utils.bytesToHex(token.signature),
p: utils.bytesToHex(token.pubkey),
e: token.expiry,
sc: token.scope,
kinds: token.kinds,
kind_ranges: token.kindRanges
}
const json = JSON.stringify(tokenData)
// Use base64url encoding
const base64 = btoa(json)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
return 'cashuA' + base64
}
/**
* Decode a token from the Cashu format.
*/
function decodeToken(encoded: string): TCashuToken {
if (!encoded.startsWith('cashuA')) {
throw new Error('Invalid token prefix, expected cashuA')
}
const base64url = encoded.slice(6)
// Convert base64url to base64
let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
// Add padding if needed
while (base64.length % 4 !== 0) {
base64 += '='
}
const json = atob(base64)
const data = JSON.parse(json)
return {
keysetId: data.k,
secret: utils.hexToBytes(data.s),
signature: utils.hexToBytes(data.c),
pubkey: utils.hexToBytes(data.p),
expiry: data.e,
scope: data.sc,
kinds: data.kinds,
kindRanges: data.kind_ranges
}
}
/**
* Cashu Token Service - manages token lifecycle for bunker auth.
*/
class CashuTokenService {
private mintUrl: string | null = null
private mintPubkey: Uint8Array | null = null
private activeKeysetId: string | null = null
/**
* Configure the mint endpoint.
*/
setMint(url: string) {
this.mintUrl = url.replace(/\/$/, '')
}
/**
* Fetch mint info and keysets.
*/
async fetchMintInfo(): Promise<TMintInfo> {
if (!this.mintUrl) {
throw new Error('Mint URL not configured')
}
const response = await fetch(`${this.mintUrl}/cashu/info`)
if (!response.ok) {
throw new Error(`Failed to fetch mint info: ${response.statusText}`)
}
const info = await response.json()
this.mintPubkey = utils.hexToBytes(info.pubkey)
// Also fetch keysets
const keysetsResponse = await fetch(`${this.mintUrl}/cashu/keysets`)
if (keysetsResponse.ok) {
const keysetsData = await keysetsResponse.json()
info.keysets = keysetsData.keysets
// Find active keyset
const active = keysetsData.keysets.find((k: TKeysetInfo) => k.active)
if (active) {
this.activeKeysetId = active.id
}
}
return info
}
/**
* Request a new token from the mint.
* Requires NIP-98 auth via the signHttpAuth function.
*/
async requestToken(
scope: TTokenScope,
userPubkey: Uint8Array,
signHttpAuth: (url: string, method: string) => Promise<string>,
kinds?: number[],
kindRanges?: [number, number][]
): Promise<TCashuToken> {
if (!this.mintUrl) {
throw new Error('Mint URL not configured')
}
// Generate secret and blind it
const secret = crypto.getRandomValues(new Uint8Array(32))
const blindResult = blind(secret)
// Create request
const requestBody = {
blinded_message: utils.bytesToHex(blindResult.B_),
scope,
kinds,
kind_ranges: kindRanges
}
// Get NIP-98 auth header
const authUrl = `${this.mintUrl}/cashu/mint`
const authHeader = await signHttpAuth(authUrl, 'POST')
// Submit to mint
const response = await fetch(authUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authHeader
},
body: JSON.stringify(requestBody)
})
if (!response.ok) {
const error = await response.text()
throw new Error(`Mint request failed: ${error}`)
}
const result = await response.json()
// Unblind the signature
const C_ = utils.hexToBytes(result.blinded_signature)
const K = utils.hexToBytes(result.mint_pubkey)
const signature = unblind(C_, blindResult.r, K)
const token: TCashuToken = {
keysetId: result.keyset_id,
secret: blindResult.secret,
signature,
pubkey: userPubkey,
expiry: result.expiry,
scope,
kinds,
kindRanges
}
return token
}
/**
* Store tokens for a specific bunker.
* Maintains current and next token for rotation.
*/
storeTokens(bunkerPubkey: string, current: TCashuToken, next?: TCashuToken) {
const key = `${STORAGE_PREFIX}${bunkerPubkey}`
const data = {
current: encodeToken(current),
next: next ? encodeToken(next) : undefined,
storedAt: Date.now()
}
localStorage.setItem(key, JSON.stringify(data))
}
/**
* Load tokens for a specific bunker.
*/
loadTokens(bunkerPubkey: string): { current?: TCashuToken; next?: TCashuToken } | null {
const key = `${STORAGE_PREFIX}${bunkerPubkey}`
const stored = localStorage.getItem(key)
if (!stored) {
return null
}
try {
const data = JSON.parse(stored)
return {
current: data.current ? decodeToken(data.current) : undefined,
next: data.next ? decodeToken(data.next) : undefined
}
} catch {
return null
}
}
/**
* Get the active token for a bunker, handling rotation if needed.
*/
getActiveToken(bunkerPubkey: string): TCashuToken | null {
const tokens = this.loadTokens(bunkerPubkey)
if (!tokens) {
return null
}
const now = Date.now() / 1000
// If current is valid, use it
if (tokens.current && tokens.current.expiry > now) {
return tokens.current
}
// Current expired, try to promote next
if (tokens.next && tokens.next.expiry > now) {
// Promote next to current
this.storeTokens(bunkerPubkey, tokens.next)
return tokens.next
}
// Both expired
return null
}
/**
* Check if token needs refresh (< 1 day until expiry).
*/
needsRefresh(token: TCashuToken): boolean {
const now = Date.now() / 1000
const oneDaySeconds = 24 * 60 * 60
return token.expiry - now < oneDaySeconds
}
/**
* Clear tokens for a bunker.
*/
clearTokens(bunkerPubkey: string) {
const key = `${STORAGE_PREFIX}${bunkerPubkey}`
localStorage.removeItem(key)
}
/**
* Encode a token for transmission (e.g., in headers).
*/
encodeToken(token: TCashuToken): string {
return encodeToken(token)
}
/**
* Decode a token string.
*/
decodeToken(encoded: string): TCashuToken {
return decodeToken(encoded)
}
/**
* Verify a token is valid.
*/
verifyToken(token: TCashuToken): boolean {
if (!this.mintPubkey) {
// Can't verify without mint pubkey, assume valid if not expired
return token.expiry > Date.now() / 1000
}
return verifyToken(token, this.mintPubkey)
}
/**
* Get the active keyset ID.
*/
getActiveKeysetId(): string | null {
return this.activeKeysetId
}
}
// Export singleton instance
const cashuTokenService = new CashuTokenService()
export default cashuTokenService
// Export utilities
export { encodeToken, decodeToken, hashToCurve, blind, unblind }

View File

@@ -10,7 +10,6 @@ import { Event, Filter } from 'nostr-tools'
import * as nip44 from 'nostr-tools/nip44' import * as nip44 from 'nostr-tools/nip44'
import * as utils from '@noble/curves/abstract/utils' import * as utils from '@noble/curves/abstract/utils'
import { finalizeEvent } from 'nostr-tools' import { finalizeEvent } from 'nostr-tools'
import { ISigner } from '@/types'
import { import {
KIND_NRC_REQUEST, KIND_NRC_REQUEST,
KIND_NRC_RESPONSE, KIND_NRC_RESPONSE,
@@ -55,8 +54,6 @@ export interface RemoteConnection {
label: string label: string
relayPubkey: string relayPubkey: string
rendezvousUrl: string rendezvousUrl: string
authMode: 'secret' | 'cat'
mintUrl?: string // For CAT mode
lastSync?: number lastSync?: number
eventCount?: number eventCount?: number
} }
@@ -87,16 +84,10 @@ export class NRCClient {
private chunkBuffers: Map<string, ChunkBuffer> = new Map() private chunkBuffers: Map<string, ChunkBuffer> = new Map()
private syncTimeout: ReturnType<typeof setTimeout> | null = null private syncTimeout: ReturnType<typeof setTimeout> | null = null
private lastActivityTime: number = 0 private lastActivityTime: number = 0
// CAT mode fields
private signer?: ISigner
private catToken?: string
private clientPubkey?: string
constructor(connectionUri: string, signer?: ISigner, catToken?: string) { constructor(connectionUri: string) {
this.uri = parseConnectionURI(connectionUri) this.uri = parseConnectionURI(connectionUri)
this.sessionId = generateSessionId() this.sessionId = generateSessionId()
this.signer = signer
this.catToken = catToken
} }
/** /**
@@ -126,12 +117,6 @@ export class NRCClient {
this.chunkBuffers.clear() this.chunkBuffers.clear()
this.lastActivityTime = Date.now() this.lastActivityTime = Date.now()
// For CAT mode, get our pubkey from the signer
if (this.uri.authMode === 'cat' && this.signer) {
this.clientPubkey = await this.signer.getPublicKey()
console.log(`[NRC Client] CAT mode, our pubkey: ${this.clientPubkey?.slice(0, 8)}...`)
}
return new Promise<Event[]>((resolve, reject) => { return new Promise<Event[]>((resolve, reject) => {
this.resolveSync = resolve this.resolveSync = resolve
this.rejectSync = reject this.rejectSync = reject
@@ -189,11 +174,6 @@ export class NRCClient {
this.lastActivityTime = Date.now() this.lastActivityTime = Date.now()
this.idsMode = true this.idsMode = true
// For CAT mode, get our pubkey from the signer
if (this.uri.authMode === 'cat' && this.signer) {
this.clientPubkey = await this.signer.getPublicKey()
}
return new Promise<EventManifestEntry[]>((resolve, reject) => { return new Promise<EventManifestEntry[]>((resolve, reject) => {
this.resolveIDs = resolve this.resolveIDs = resolve
this.rejectIDs = reject this.rejectIDs = reject
@@ -259,11 +239,6 @@ export class NRCClient {
this.eventsSentCount = 0 this.eventsSentCount = 0
this.eventsToSend = [...events] this.eventsToSend = [...events]
// For CAT mode, get our pubkey from the signer
if (this.uri.authMode === 'cat' && this.signer) {
this.clientPubkey = await this.signer.getPublicKey()
}
return new Promise<number>((resolve, reject) => { return new Promise<number>((resolve, reject) => {
this.resolveSend = resolve this.resolveSend = resolve
@@ -379,8 +354,7 @@ export class NRCClient {
// Subscribe to responses for our client pubkey // Subscribe to responses for our client pubkey
const responseSubId = generateSubId() const responseSubId = generateSubId()
// Use CAT-mode pubkey if available, otherwise use secret-derived pubkey const clientPubkey = this.uri.clientPubkey
const clientPubkey = this.clientPubkey || this.uri.clientPubkey
if (!clientPubkey) { if (!clientPubkey) {
reject(new Error('Client pubkey not available')) reject(new Error('Client pubkey not available'))
@@ -461,69 +435,35 @@ export class NRCClient {
throw new Error('Not connected') throw new Error('Not connected')
} }
if (!this.uri.clientPrivkey || !this.uri.clientPubkey) {
throw new Error('Missing keys')
}
const plaintext = JSON.stringify(request) const plaintext = JSON.stringify(request)
let encrypted: string
let signedEvent: Event
if (this.uri.authMode === 'cat' && this.signer && this.clientPubkey) { // Derive conversation key
// CAT mode: use signer for encryption and signing const conversationKey = deriveConversationKey(
if (!this.signer.nip44Encrypt) { this.uri.clientPrivkey,
throw new Error('Signer does not support NIP-44 encryption') this.uri.relayPubkey
} )
encrypted = await this.signer.nip44Encrypt(this.uri.relayPubkey, plaintext) const encrypted = nip44.v2.encrypt(plaintext, conversationKey)
// Build the request event with CAT token // Build the request event
const tags: string[][] = [ const unsignedEvent = {
kind: KIND_NRC_REQUEST,
content: encrypted,
tags: [
['p', this.uri.relayPubkey], ['p', this.uri.relayPubkey],
['encryption', 'nip44_v2'], ['encryption', 'nip44_v2'],
['session', this.sessionId] ['session', this.sessionId]
] ],
created_at: Math.floor(Date.now() / 1000),
// Add CAT token if available pubkey: this.uri.clientPubkey
if (this.catToken) {
tags.push(['cashu', this.catToken])
}
const unsignedEvent = {
kind: KIND_NRC_REQUEST,
content: encrypted,
tags,
created_at: Math.floor(Date.now() / 1000)
}
signedEvent = await this.signer.signEvent(unsignedEvent)
console.log(`[NRC Client] CAT mode: Sent encrypted REQ with CAT token`)
} else {
// Secret mode: use derived keys
if (!this.uri.clientPrivkey || !this.uri.clientPubkey) {
throw new Error('Missing keys for secret mode')
}
// Derive conversation key
const conversationKey = deriveConversationKey(
this.uri.clientPrivkey,
this.uri.relayPubkey
)
encrypted = nip44.v2.encrypt(plaintext, conversationKey)
// Build the request event
const unsignedEvent = {
kind: KIND_NRC_REQUEST,
content: encrypted,
tags: [
['p', this.uri.relayPubkey],
['encryption', 'nip44_v2'],
['session', this.sessionId]
],
created_at: Math.floor(Date.now() / 1000),
pubkey: this.uri.clientPubkey
}
signedEvent = finalizeEvent(unsignedEvent, this.uri.clientPrivkey)
} }
const signedEvent = finalizeEvent(unsignedEvent, this.uri.clientPrivkey)
// Send to rendezvous relay // Send to rendezvous relay
this.ws.send(JSON.stringify(['EVENT', signedEvent])) this.ws.send(JSON.stringify(['EVENT', signedEvent]))
console.log(`[NRC Client] Sent encrypted REQ, event id: ${signedEvent.id?.slice(0, 8)}..., p-tag: ${this.uri.relayPubkey?.slice(0, 8)}...`) console.log(`[NRC Client] Sent encrypted REQ, event id: ${signedEvent.id?.slice(0, 8)}..., p-tag: ${this.uri.relayPubkey?.slice(0, 8)}...`)
@@ -578,27 +518,16 @@ export class NRCClient {
* Decrypt and process a response event * Decrypt and process a response event
*/ */
private async decryptAndProcessResponse(event: Event): Promise<void> { private async decryptAndProcessResponse(event: Event): Promise<void> {
let plaintext: string if (!this.uri.clientPrivkey) {
throw new Error('Missing private key for decryption')
if (this.uri.authMode === 'cat' && this.signer) {
// CAT mode: use signer for decryption
if (!this.signer.nip44Decrypt) {
throw new Error('Signer does not support NIP-44 decryption')
}
plaintext = await this.signer.nip44Decrypt(event.pubkey, event.content)
} else {
// Secret mode: use derived key
if (!this.uri.clientPrivkey) {
throw new Error('Missing private key for decryption')
}
const conversationKey = deriveConversationKey(
this.uri.clientPrivkey,
this.uri.relayPubkey
)
plaintext = nip44.v2.decrypt(event.content, conversationKey)
} }
const conversationKey = deriveConversationKey(
this.uri.clientPrivkey,
this.uri.relayPubkey
)
const plaintext = nip44.v2.decrypt(event.content, conversationKey)
const response: ResponseMessage = JSON.parse(plaintext) const response: ResponseMessage = JSON.parse(plaintext)
console.log(`[NRC Client] Received response: ${response.type}`) console.log(`[NRC Client] Received response: ${response.type}`)
@@ -787,18 +716,14 @@ export class NRCClient {
* @param connectionUri - The nostr+relayconnect:// URI * @param connectionUri - The nostr+relayconnect:// URI
* @param filters - Nostr filters for events to sync * @param filters - Nostr filters for events to sync
* @param onProgress - Optional progress callback * @param onProgress - Optional progress callback
* @param signer - Optional signer for CAT mode
* @param catToken - Optional CAT token for CAT mode
* @returns Array of synced events * @returns Array of synced events
*/ */
export async function syncFromRemote( export async function syncFromRemote(
connectionUri: string, connectionUri: string,
filters: Filter[], filters: Filter[],
onProgress?: (progress: SyncProgress) => void, onProgress?: (progress: SyncProgress) => void
signer?: ISigner,
catToken?: string
): Promise<Event[]> { ): Promise<Event[]> {
const client = new NRCClient(connectionUri, signer, catToken) const client = new NRCClient(connectionUri)
return client.sync(filters, onProgress) return client.sync(filters, onProgress)
} }
@@ -808,17 +733,13 @@ export async function syncFromRemote(
* *
* @param connectionUri - The nostr+relayconnect:// URI * @param connectionUri - The nostr+relayconnect:// URI
* @param onProgress - Optional progress callback * @param onProgress - Optional progress callback
* @param signer - Optional signer for CAT mode
* @param catToken - Optional CAT token for CAT mode
* @returns true if connection successful * @returns true if connection successful
*/ */
export async function testConnection( export async function testConnection(
connectionUri: string, connectionUri: string,
onProgress?: (progress: SyncProgress) => void, onProgress?: (progress: SyncProgress) => void
signer?: ISigner,
catToken?: string
): Promise<boolean> { ): Promise<boolean> {
const client = new NRCClient(connectionUri, signer, catToken) const client = new NRCClient(connectionUri)
try { try {
// Request just one profile event to test the full round-trip // Request just one profile event to test the full round-trip
const events = await client.sync( const events = await client.sync(
@@ -840,18 +761,14 @@ export async function testConnection(
* @param connectionUri - The nostr+relayconnect:// URI * @param connectionUri - The nostr+relayconnect:// URI
* @param filters - Filters to match events * @param filters - Filters to match events
* @param onProgress - Optional progress callback * @param onProgress - Optional progress callback
* @param signer - Optional signer for CAT mode
* @param catToken - Optional CAT token for CAT mode
* @returns Array of event manifest entries (id, kind, created_at, d) * @returns Array of event manifest entries (id, kind, created_at, d)
*/ */
export async function requestRemoteIDs( export async function requestRemoteIDs(
connectionUri: string, connectionUri: string,
filters: Filter[], filters: Filter[],
onProgress?: (progress: SyncProgress) => void, onProgress?: (progress: SyncProgress) => void
signer?: ISigner,
catToken?: string
): Promise<EventManifestEntry[]> { ): Promise<EventManifestEntry[]> {
const client = new NRCClient(connectionUri, signer, catToken) const client = new NRCClient(connectionUri)
return client.requestIDs(filters, onProgress) return client.requestIDs(filters, onProgress)
} }
@@ -861,17 +778,13 @@ export async function requestRemoteIDs(
* @param connectionUri - The nostr+relayconnect:// URI * @param connectionUri - The nostr+relayconnect:// URI
* @param events - Events to send * @param events - Events to send
* @param onProgress - Optional progress callback * @param onProgress - Optional progress callback
* @param signer - Optional signer for CAT mode
* @param catToken - Optional CAT token for CAT mode
* @returns Number of events successfully stored * @returns Number of events successfully stored
*/ */
export async function sendEventsToRemote( export async function sendEventsToRemote(
connectionUri: string, connectionUri: string,
events: Event[], events: Event[],
onProgress?: (progress: SyncProgress) => void, onProgress?: (progress: SyncProgress) => void
signer?: ISigner,
catToken?: string
): Promise<number> { ): Promise<number> {
const client = new NRCClient(connectionUri, signer, catToken) const client = new NRCClient(connectionUri)
return client.sendEvents(events, onProgress) return client.sendEvents(events, onProgress)
} }

View File

@@ -14,7 +14,6 @@
import { Event, Filter } from 'nostr-tools' import { Event, Filter } from 'nostr-tools'
import * as utils from '@noble/curves/abstract/utils' import * as utils from '@noble/curves/abstract/utils'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import cashuTokenService, { decodeToken, TCashuToken } from '@/services/cashu-token.service'
import { import {
KIND_NRC_REQUEST, KIND_NRC_REQUEST,
KIND_NRC_RESPONSE, KIND_NRC_RESPONSE,
@@ -291,7 +290,6 @@ export class NRCListenerService {
const session = this.sessions.getOrCreateSession( const session = this.sessions.getOrCreateSession(
event.pubkey, event.pubkey,
undefined, // We use signer's nip44 methods instead of conversationKey undefined, // We use signer's nip44 methods instead of conversationKey
authResult.mode,
authResult.deviceName authResult.deviceName
) )
@@ -344,17 +342,6 @@ export class NRCListenerService {
throw new Error('Listener not configured') throw new Error('Listener not configured')
} }
// Check for CAT token in cashu tag
const cashuTag = event.tags.find((t) => t[0] === 'cashu')
if (cashuTag && cashuTag[1] && this.config.catConfig) {
const catResult = await this.verifyCATToken(cashuTag[1], event.pubkey)
if (catResult) {
return catResult
}
// CAT verification failed, fall through to check other auth methods
console.log('[NRC] CAT verification failed, checking other auth methods')
}
// Secret-based auth: check if pubkey is authorized // Secret-based auth: check if pubkey is authorized
const deviceName = this.config.authorizedSecrets.get(event.pubkey) const deviceName = this.config.authorizedSecrets.get(event.pubkey)
if (!deviceName) { if (!deviceName) {
@@ -365,72 +352,10 @@ export class NRCListenerService {
} }
return { return {
mode: 'secret',
deviceName deviceName
} }
} }
/**
* Verify a CAT (Cashu Access Token) for NRC authentication
*/
private async verifyCATToken(encodedToken: string, clientPubkey: string): Promise<AuthResult | null> {
if (!this.config?.catConfig) {
return null
}
try {
// Decode the token
const token: TCashuToken = decodeToken(encodedToken)
console.log('[NRC] Verifying CAT token, scope:', token.scope, 'expiry:', new Date(token.expiry * 1000))
// Check expiry
const now = Math.floor(Date.now() / 1000)
if (token.expiry < now) {
console.log('[NRC] CAT token expired')
return null
}
// Check scope - must be 'nrc' or 'relay' for NRC auth
if (token.scope !== 'nrc' && token.scope !== 'relay') {
console.log('[NRC] CAT token has wrong scope:', token.scope)
return null
}
// Verify the token pubkey matches the event pubkey
const tokenPubkeyHex = utils.bytesToHex(token.pubkey)
if (tokenPubkeyHex !== clientPubkey) {
console.log('[NRC] CAT token pubkey mismatch:', tokenPubkeyHex, '!=', clientPubkey)
return null
}
// Initialize the cashu service with the mint URL if not already done
cashuTokenService.setMint(this.config.catConfig.mintUrl)
// Verify token signature with mint
// Note: This requires the mint info to be fetched first
try {
await cashuTokenService.fetchMintInfo()
} catch (err) {
console.warn('[NRC] Could not fetch mint info for CAT verification:', err)
// Continue anyway - we've done basic validation
}
if (!cashuTokenService.verifyToken(token)) {
console.log('[NRC] CAT token signature verification failed')
return null
}
console.log('[NRC] CAT token verified successfully')
return {
mode: 'cat',
deviceName: `cat:${clientPubkey.slice(0, 8)}`
}
} catch (err) {
console.error('[NRC] CAT token verification error:', err)
return null
}
}
/** /**
* Decrypt content using the signer's NIP-44 implementation * Decrypt content using the signer's NIP-44 implementation
*/ */

View File

@@ -1,5 +1,5 @@
import { Filter } from 'nostr-tools' import { Filter } from 'nostr-tools'
import { NRCSession, NRCSubscription, AuthMode } from './nrc-types' import { NRCSession, NRCSubscription } from './nrc-types'
// Default session timeout: 30 minutes // Default session timeout: 30 minutes
const DEFAULT_SESSION_TIMEOUT = 30 * 60 * 1000 const DEFAULT_SESSION_TIMEOUT = 30 * 60 * 1000
@@ -60,7 +60,6 @@ export class NRCSessionManager {
getOrCreateSession( getOrCreateSession(
clientPubkey: string, clientPubkey: string,
conversationKey: Uint8Array | undefined, conversationKey: Uint8Array | undefined,
authMode: AuthMode,
deviceName?: string deviceName?: string
): NRCSession { ): NRCSession {
// Check if session exists for this client // Check if session exists for this client
@@ -78,7 +77,6 @@ export class NRCSessionManager {
clientPubkey, clientPubkey,
conversationKey, conversationKey,
deviceName, deviceName,
authMode,
createdAt: Date.now(), createdAt: Date.now(),
lastActivity: Date.now(), lastActivity: Date.now(),
subscriptions: new Map() subscriptions: new Map()

View File

@@ -5,16 +5,12 @@ import { ISigner } from '@/types'
export const KIND_NRC_REQUEST = 24891 export const KIND_NRC_REQUEST = 24891
export const KIND_NRC_RESPONSE = 24892 export const KIND_NRC_RESPONSE = 24892
// Authentication modes
export type AuthMode = 'secret' | 'cat'
// Session types // Session types
export interface NRCSession { export interface NRCSession {
id: string id: string
clientPubkey: string clientPubkey: string
conversationKey?: Uint8Array // Optional - only set when using direct key access conversationKey?: Uint8Array // Optional - only set when using direct key access
deviceName?: string deviceName?: string
authMode: AuthMode
createdAt: number createdAt: number
lastActivity: number lastActivity: number
subscriptions: Map<string, NRCSubscription> subscriptions: Map<string, NRCSubscription>
@@ -72,30 +68,21 @@ export interface NRCConnection {
label: string label: string
secret?: string // For secret-based auth secret?: string // For secret-based auth
clientPubkey?: string // Derived from secret clientPubkey?: string // Derived from secret
useCat: boolean // Whether to use CAT auth
createdAt: number createdAt: number
lastUsed?: number lastUsed?: number
} }
// CAT (Cashu Access Token) configuration
export interface CATConfig {
mintUrl: string // Cashu mint URL
scope: string // Token scope (e.g., "nrc")
}
// Listener configuration // Listener configuration
export interface NRCListenerConfig { export interface NRCListenerConfig {
rendezvousUrl: string rendezvousUrl: string
signer: ISigner signer: ISigner
authorizedSecrets: Map<string, string> // clientPubkey → deviceName authorizedSecrets: Map<string, string> // clientPubkey → deviceName
catConfig?: CATConfig // For CAT verification
sessionTimeout?: number // Session inactivity timeout in ms (default 30 min) sessionTimeout?: number // Session inactivity timeout in ms (default 30 min)
maxSubscriptionsPerSession?: number // Max subscriptions per session (default 100) maxSubscriptionsPerSession?: number // Max subscriptions per session (default 100)
} }
// Authorization result // Authorization result
export interface AuthResult { export interface AuthResult {
mode: AuthMode
conversationKey?: Uint8Array // Optional - only set when using direct key access conversationKey?: Uint8Array // Optional - only set when using direct key access
deviceName: string deviceName: string
} }
@@ -104,13 +91,10 @@ export interface AuthResult {
export interface ParsedConnectionURI { export interface ParsedConnectionURI {
relayPubkey: string // Hex pubkey of the listening relay/client relayPubkey: string // Hex pubkey of the listening relay/client
rendezvousUrl: string // URL of the rendezvous relay rendezvousUrl: string // URL of the rendezvous relay
authMode: AuthMode
// For secret-based auth // For secret-based auth
secret?: string // 32-byte hex secret secret?: string // 32-byte hex secret
clientPubkey?: string // Derived pubkey from secret clientPubkey?: string // Derived pubkey from secret
clientPrivkey?: Uint8Array // Derived private key from secret clientPrivkey?: Uint8Array // Derived private key from secret
// For CAT auth
mintUrl?: string
// Optional // Optional
deviceName?: string deviceName?: string
} }
@@ -121,7 +105,6 @@ export interface NRCListenerState {
isListening: boolean isListening: boolean
connections: NRCConnection[] connections: NRCConnection[]
activeSessions: number activeSessions: number
catConfig: CATConfig | null
rendezvousUrl: string rendezvousUrl: string
} }

View File

@@ -1,32 +1,7 @@
import * as utils from '@noble/curves/abstract/utils' import * as utils from '@noble/curves/abstract/utils'
import { getPublicKey } from 'nostr-tools' import { getPublicKey } from 'nostr-tools'
import * as nip44 from 'nostr-tools/nip44' import * as nip44 from 'nostr-tools/nip44'
import { ParsedConnectionURI, AuthMode } from './nrc-types' import { ParsedConnectionURI } from './nrc-types'
/**
* Derive the Cashu mint URL from a relay URL.
* The mint is always at {relay-root}/cashu
*
* @param relayUrl - WebSocket relay URL (ws:// or wss://)
* @returns HTTP(S) mint URL
*/
export function deriveMintUrlFromRelay(relayUrl: string): string {
let mintUrl = relayUrl
// Convert WebSocket URL to HTTP URL
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
}
// Remove trailing slash and append /cashu
mintUrl = mintUrl.replace(/\/$/, '')
return mintUrl
}
/** /**
* Generate a random 32-byte secret as hex string * Generate a random 32-byte secret as hex string
@@ -91,28 +66,6 @@ export function generateConnectionURI(
return { uri, secret: secretHex, clientPubkey } return { uri, secret: secretHex, clientPubkey }
} }
/**
* Generate a CAT-based NRC connection URI
*
* The mint URL is derived automatically from the rendezvous relay URL
* (mint is always at {relay-root}/cashu)
*
* @param relayPubkey - The public key of the listening client/relay
* @param rendezvousUrl - The URL of the rendezvous relay
* @returns The connection URI
*/
export function generateCATConnectionURI(
relayPubkey: string,
rendezvousUrl: string
): string {
const params = new URLSearchParams()
params.set('relay', rendezvousUrl)
params.set('auth', 'cat')
// Note: mint URL is derived from relay URL, not stored in URI
return `nostr+relayconnect://${relayPubkey}?${params.toString()}`
}
/** /**
* Parse an NRC connection URI * Parse an NRC connection URI
* *
@@ -153,49 +106,30 @@ export function parseConnectionURI(uri: string): ParsedConnectionURI {
throw new Error('Invalid rendezvous relay URL') throw new Error('Invalid rendezvous relay URL')
} }
// Determine auth mode
const authParam = url.searchParams.get('auth')
const authMode: AuthMode = authParam === 'cat' ? 'cat' : 'secret'
// Extract device name (optional) // Extract device name (optional)
const deviceName = url.searchParams.get('name') || undefined const deviceName = url.searchParams.get('name') || undefined
if (authMode === 'cat') { // Secret-based auth
// CAT-based auth - mint URL is derived from relay URL const secret = url.searchParams.get('secret')
// (mint is always at {relay-root}/cashu) if (!secret) {
const mintUrl = deriveMintUrlFromRelay(rendezvousUrl) throw new Error('Missing secret parameter in URI')
}
return { // Validate secret format (64 hex chars = 32 bytes)
relayPubkey, if (!/^[0-9a-fA-F]{64}$/.test(secret)) {
rendezvousUrl, throw new Error('Invalid secret format, expected 64 hex characters')
authMode, }
mintUrl,
deviceName
}
} else {
// Secret-based auth
const secret = url.searchParams.get('secret')
if (!secret) {
throw new Error('Secret auth requires secret parameter')
}
// Validate secret format (64 hex chars = 32 bytes) // Derive keypair from secret
if (!/^[0-9a-fA-F]{64}$/.test(secret)) { const { privkey, pubkey } = deriveKeypairFromSecret(secret)
throw new Error('Invalid secret format, expected 64 hex characters')
}
// Derive keypair from secret return {
const { privkey, pubkey } = deriveKeypairFromSecret(secret) relayPubkey,
rendezvousUrl,
return { secret,
relayPubkey, clientPubkey: pubkey,
rendezvousUrl, clientPrivkey: privkey,
authMode, deviceName
secret,
clientPubkey: pubkey,
clientPrivkey: privkey,
deviceName
}
} }
} }
@@ -211,31 +145,3 @@ export function isValidConnectionURI(uri: string): boolean {
return false return false
} }
} }
/**
* Check if a relay supports CAT (Cashu Access Tokens)
* by probing the /cashu/info endpoint
*
* @param relayUrl - WebSocket relay URL
* @returns true if the relay has a Cashu mint
*/
export async function relaySupportsCat(relayUrl: string): Promise<boolean> {
const mintUrl = deriveMintUrlFromRelay(relayUrl)
try {
const response = await fetch(`${mintUrl}/cashu/info`, {
method: 'GET',
headers: { Accept: 'application/json' }
})
if (!response.ok) {
return false
}
// Check if response is valid mint info
const info = await response.json()
return info && typeof info === 'object' && 'name' in info
} catch {
return false
}
}

View File

@@ -123,7 +123,6 @@ export type TAccount = {
bunkerPubkey?: string bunkerPubkey?: string
bunkerRelays?: string[] bunkerRelays?: string[]
bunkerSecret?: string bunkerSecret?: string
bunkerCatToken?: string
} }
export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'> export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>