diff --git a/frontend/src/components/BetPanel.vue b/frontend/src/components/BetPanel.vue index 9c7d618..0839473 100644 --- a/frontend/src/components/BetPanel.vue +++ b/frontend/src/components/BetPanel.vue @@ -2,6 +2,7 @@ import { ref, computed, onMounted } from 'vue' import { useWallet } from '../composables/useWallet' import { useNostr } from '../composables/useNostr' +import { authFetch } from '../lib/nostr-auth' const props = defineProps<{ fightId: string @@ -65,7 +66,7 @@ async function placeBet() { body.cashuToken = cashuToken.value.trim() } - const res = await fetch('/api/bets/place', { + const res = await authFetch('/api/bets/place', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), diff --git a/frontend/src/composables/useNostr.ts b/frontend/src/composables/useNostr.ts index 0e1eb93..98adbd8 100644 --- a/frontend/src/composables/useNostr.ts +++ b/frontend/src/composables/useNostr.ts @@ -1,7 +1,8 @@ import { ref, readonly, computed } from 'vue' -import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools' +import { generateSecretKey, getPublicKey } from 'nostr-tools' import { bytesToHex, hexToBytes } from 'nostr-tools/utils' import { nsecEncode } from 'nostr-tools/nip19' +import { buildNip98Token, setToken, getToken, isTokenExpired, authFetch } from '../lib/nostr-auth' interface BotCustomization { archetype?: string @@ -65,7 +66,7 @@ function normalizeBotData(data: Record): BotData { } as BotData } -/** Clear all auth-related state */ +/** Clear all auth-related state including JWT and nsec */ function clearAllState() { pubkey.value = null bot.value = null @@ -73,6 +74,8 @@ function clearAllState() { store('bf_pubkey', null) store('bf_bot', null) store('bf_pic', null) + setToken(null) + localStorage.removeItem('bf_nsec') } const pubkey = ref(loadStored('bf_pubkey')) @@ -85,89 +88,109 @@ let autoRestoreRan = false // Flag: skip relay pic fetch for freshly generated keys (no profile exists) let freshlyGenerated = false +// Auto-restore session from JWT on first load +if (!autoRestoreRan && pubkey.value && !bot.value && getToken() && !isTokenExpired()) { + autoRestoreRan = true + authFetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pubkey: pubkey.value }), + }).then(r => r.json()).then(data => { + if (data.exists) { + bot.value = normalizeBotData(data.bot) + store('bf_bot', bot.value) + } + }).catch(() => {}) +} else if (!autoRestoreRan && pubkey.value && !getToken()) { + // No JWT — clear stale pubkey from before the JWT migration + autoRestoreRan = true + pubkey.value = null + store('bf_pubkey', null) +} + export function useNostr() { const isLoggedIn = computed(() => !!pubkey.value && !!bot.value) const hasExtension = computed(() => !!window.nostr) - // Restore session on first load — re-verify with server (once only) - const autoRestoreController = new AbortController() - if (!autoRestoreRan && pubkey.value && !bot.value) { - autoRestoreRan = true - fetch('/api/auth/login', { + /** + * Authenticate with the server using NIP-98 signed request. + * Works with NIP-07 extension, Amber signer, or local nsec. + * Returns JWT session token + bot data. + */ + async function authenticateSession(secretKeyHex?: string | null): Promise<{ pubkey: string; bot: BotData | null }> { + const sessionUrl = new URL('/api/auth/nostr/session', window.location.origin).toString() + const nip98Token = await buildNip98Token(sessionUrl, 'POST', secretKeyHex) + + const res = await fetch('/api/auth/nostr/session', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pubkey: pubkey.value }), - signal: autoRestoreController.signal, - }).then(r => r.json()).then(data => { - if (data.exists) { - bot.value = normalizeBotData(data.bot) - store('bf_bot', bot.value) - } - }).catch(() => {}) + headers: { + 'Authorization': `Nostr ${nip98Token}`, + }, + }) + + if (!res.ok) { + const data = await res.json().catch(() => ({ error: 'Authentication failed' })) + throw new Error(data.error || 'NIP-98 authentication failed') + } + + const data = await res.json() + + // Store JWT + setToken(data.token) + + // Store pubkey + pubkey.value = data.pubkey + store('bf_pubkey', data.pubkey) + + if (data.exists && data.bot) { + bot.value = normalizeBotData(data.bot) + store('bf_bot', bot.value) + return { pubkey: data.pubkey, bot: bot.value } + } + + bot.value = null + store('bf_bot', null) + return { pubkey: data.pubkey, bot: null } } + /** + * Sign in using NIP-07 extension or Amber signer (window.nostr). + * Authenticates via NIP-98 and receives a JWT session. + */ async function login(): Promise<{ pubkey: string; bot: BotData | null }> { - let pk: string - - if (window.nostr) { - try { - pk = await window.nostr.getPublicKey() - } catch { - throw new Error('Nostr extension denied access. Approve the request and try again.') - } - } else { + if (!window.nostr) { + // Fall back to stored nsec if available const storedNsec = localStorage.getItem('bf_nsec') if (storedNsec) { - try { - const secretKey = hexToBytes(storedNsec) - pk = getPublicKey(secretKey) - } catch { - throw new Error('Stored key is corrupt. Generate a new identity.') - } - } else { - throw new Error('No Nostr extension found and no saved key. Use "Generate Login" to create one.') + return loginWithNsec(storedNsec) } + throw new Error('No Nostr signer found. Install a browser extension or use "Generate New Identity".') + } + + // Verify extension is responsive + try { + await window.nostr.getPublicKey() + } catch { + throw new Error('Nostr signer denied access. Approve the request and try again.') } isLoading.value = true try { - pubkey.value = pk - store('bf_pubkey', pk) + const result = await authenticateSession() - // Fetch Nostr profile pic from relay (skip for freshly generated keys — no profile exists) - if (freshlyGenerated) { - freshlyGenerated = false - profilePicUrl.value = null - store('bf_pic', null) - } else { - // Non-blocking: start fetch but don't block login on it - fetchNostrProfilePic(pk).then(pic => { + // Fetch Nostr profile pic (non-blocking) + if (!freshlyGenerated) { + fetchNostrProfilePic(result.pubkey).then(pic => { profilePicUrl.value = pic store('bf_pic', pic) }).catch(() => {}) + } else { + freshlyGenerated = false + profilePicUrl.value = null + store('bf_pic', null) } - // Check if this pubkey has a bot - const res = await fetch('/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pubkey: pk }), - }) - - if (res.ok) { - const data = await res.json() - if (data.exists) { - bot.value = normalizeBotData(data.bot) - store('bf_bot', bot.value) - return { pubkey: pk, bot: bot.value } - } - } - - // No bot for this key — clear stale state - bot.value = null - store('bf_bot', null) - - return { pubkey: pk, bot: null } + return result } finally { isLoading.value = false } @@ -186,7 +209,7 @@ export function useNostr() { // Mark as freshly generated so login() skips relay pic fetch freshlyGenerated = true - // Store new key + // Store new key (will be cleared on logout) localStorage.setItem('bf_nsec', nsecHex) pubkey.value = pk store('bf_pubkey', pk) @@ -194,7 +217,7 @@ export function useNostr() { return { pubkey: pk, nsec: nsecBech32 } } - /** Login with an existing nsec (hex) */ + /** Login with an existing nsec (hex). Signs NIP-98 locally. */ async function loginWithNsec(nsecHex: string): Promise<{ pubkey: string; bot: BotData | null }> { let secretKey: Uint8Array try { @@ -202,7 +225,7 @@ export function useNostr() { } catch { throw new Error('Invalid secret key format.') } - const pk = getPublicKey(secretKey) + getPublicKey(secretKey) // validate key // Clear stale state before switching identity bot.value = null @@ -211,33 +234,18 @@ export function useNostr() { store('bf_pic', null) localStorage.setItem('bf_nsec', nsecHex) - pubkey.value = pk - store('bf_pubkey', pk) isLoading.value = true try { + const result = await authenticateSession(nsecHex) + // Fetch Nostr profile pic (non-blocking) - fetchNostrProfilePic(pk).then(pic => { + fetchNostrProfilePic(result.pubkey).then(pic => { profilePicUrl.value = pic store('bf_pic', pic) }).catch(() => {}) - const res = await fetch('/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pubkey: pk }), - }) - - if (res.ok) { - const data = await res.json() - if (data.exists) { - bot.value = normalizeBotData(data.bot) - store('bf_bot', bot.value) - return { pubkey: pk, bot: bot.value } - } - } - - return { pubkey: pk, bot: null } + return result } finally { isLoading.value = false } @@ -246,7 +254,7 @@ export function useNostr() { async function registerBot(name: string, webhookUrl: string, archetype: string): Promise { if (!pubkey.value) throw new Error('Not logged in') - const res = await fetch('/api/auth/register', { + const res = await authFetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -260,7 +268,6 @@ export function useNostr() { const data = await res.json() if (!res.ok) { - // Include retry timer info for rate limits let msg = data.details ? `${data.error} ${data.details}` : (data.error || 'Registration failed') if (res.status === 429 && data.retryAfterSec) msg += ` (${data.retryAfterSec}s)` throw new Error(msg) @@ -285,13 +292,21 @@ export function useNostr() { } store('bf_bot', bot.value) + // Re-authenticate to get fresh JWT with botId + try { + const nsec = localStorage.getItem('bf_nsec') + await authenticateSession(nsec) + } catch { + // Non-critical: existing JWT still works, just missing botId + } + return bot.value } async function updateCustomization(customization: BotCustomization): Promise { if (!pubkey.value) throw new Error('Not logged in') - const res = await fetch('/api/auth/update', { + const res = await authFetch('/api/auth/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: pubkey.value, customization }), @@ -315,7 +330,7 @@ export function useNostr() { async function updateWebhook(newUrl: string): Promise<{ latencyMs: number }> { if (!pubkey.value) throw new Error('Not logged in') - const res = await fetch('/api/auth/update', { + const res = await authFetch('/api/auth/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: pubkey.value, webhookUrl: newUrl }), @@ -333,7 +348,7 @@ export function useNostr() { async function registerHuman(name: string, avatarSeed?: string): Promise { if (!pubkey.value) throw new Error('Not logged in') - const res = await fetch('/api/auth/register-human', { + const res = await authFetch('/api/auth/register-human', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -371,13 +386,20 @@ export function useNostr() { } store('bf_bot', bot.value) + // Re-authenticate to get fresh JWT with botId + try { + const nsec = localStorage.getItem('bf_nsec') + await authenticateSession(nsec) + } catch { + // Non-critical + } + return bot.value } + /** Sign out — clears everything including JWT, nsec, and all stored state */ function logout() { - autoRestoreController.abort() clearAllState() - // Don't clear bf_nsec on logout — user may want to log back in } /** Check if user has a locally stored key (no extension needed) */ diff --git a/frontend/src/composables/useWallet.ts b/frontend/src/composables/useWallet.ts index c9856fe..251545c 100644 --- a/frontend/src/composables/useWallet.ts +++ b/frontend/src/composables/useWallet.ts @@ -4,6 +4,7 @@ import { finalizeEvent } from 'nostr-tools' import * as nip04 from 'nostr-tools/nip04' import * as nip44 from 'nostr-tools/nip44' import { hexToBytes, bytesToHex } from 'nostr-tools/utils' +import { authFetch } from '../lib/nostr-auth' type WalletMethod = 'nwc' | 'lnaddress' | null type PaymentStatus = 'idle' | 'invoiced' | 'paying' | 'confirmed' | 'failed' @@ -59,7 +60,7 @@ export function useWallet() { // Validate the NWC URL format parseNwcUrl(connectionString) - const res = await fetch('/api/payments/connect-wallet', { + const res = await authFetch('/api/payments/connect-wallet', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -88,7 +89,7 @@ export function useWallet() { throw new Error('Invalid Lightning Address format') } - const res = await fetch('/api/payments/connect-wallet', { + const res = await authFetch('/api/payments/connect-wallet', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -111,7 +112,7 @@ export function useWallet() { async function disconnectWallet(): Promise { if (!pubkey.value) return - await fetch('/api/payments/disconnect-wallet', { + await authFetch('/api/payments/disconnect-wallet', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pubkey: pubkey.value }), @@ -128,7 +129,7 @@ export function useWallet() { async function checkWalletStatus(): Promise { if (!pubkey.value) return - const res = await fetch(`/api/payments/wallet-status?pubkey=${pubkey.value}`) + const res = await authFetch(`/api/payments/wallet-status?pubkey=${pubkey.value}`) if (res.ok) { const data = await res.json() isWalletConnected.value = data.connected @@ -144,7 +145,7 @@ export function useWallet() { try { // Create invoice - const invoiceRes = await fetch('/api/payments/create-invoice', { + const invoiceRes = await authFetch('/api/payments/create-invoice', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ botId, pubkey: pubkey.value }), @@ -175,7 +176,7 @@ export function useWallet() { const preimage = await payViaNWC(nwcUrl, bolt11) // Tell server payment is confirmed (skip lookup_invoice polling) - await fetch(`/api/payments/confirm/${paymentId}`, { + await authFetch(`/api/payments/confirm/${paymentId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ preimage, pubkey: pubkey.value }), @@ -187,7 +188,7 @@ export function useWallet() { // No NWC — poll for confirmation (manual payment / QR code flow) for (let i = 0; i < 30; i++) { await new Promise(r => setTimeout(r, 2000)) - const checkRes = await fetch(`/api/payments/check/${paymentId}`) + const checkRes = await authFetch(`/api/payments/check/${paymentId}`) if (checkRes.ok) { const { status } = await checkRes.json() if (status === 'confirmed') { @@ -212,7 +213,7 @@ export function useWallet() { } async function submitCashuToken(botId: string, token: string): Promise { - const res = await fetch('/api/payments/submit-cashu', { + const res = await authFetch('/api/payments/submit-cashu', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ botId, token }), diff --git a/frontend/src/lib/nostr-auth.ts b/frontend/src/lib/nostr-auth.ts new file mode 100644 index 0000000..b261e05 --- /dev/null +++ b/frontend/src/lib/nostr-auth.ts @@ -0,0 +1,108 @@ +/** + * NIP-98 HTTP Auth + JWT session management. + * + * - buildNip98Token: Creates a signed NIP-98 (kind 27235) event for HTTP auth + * - authFetch: Wrapper around fetch() that attaches JWT Bearer token + * - Token storage: JWT persisted in localStorage as 'bf_token' + */ + +// --------------------------------------------------------------------------- +// JWT Token Storage +// --------------------------------------------------------------------------- + +let currentToken: string | null = localStorage.getItem('bf_token') + +export function getToken(): string | null { + return currentToken +} + +export function setToken(token: string | null) { + currentToken = token + if (token) localStorage.setItem('bf_token', token) + else localStorage.removeItem('bf_token') +} + +/** Check if stored JWT is expired (without validating signature) */ +export function isTokenExpired(): boolean { + if (!currentToken) return true + try { + const parts = currentToken.split('.') + if (parts.length !== 3) return true + const payload = JSON.parse(atob(parts[1])) + if (!payload.exp) return true + return payload.exp <= Math.floor(Date.now() / 1000) + } catch { + return true + } +} + +// --------------------------------------------------------------------------- +// NIP-98 Token Builder +// --------------------------------------------------------------------------- + +interface NostrSigner { + getPublicKey(): Promise + signEvent(event: Record): Promise> +} + +/** + * Build a NIP-98 HTTP Auth token (base64-encoded signed kind 27235 event). + * + * Uses window.nostr (NIP-07 extension / Amber) if available, + * otherwise signs locally with the provided secret key bytes. + */ +export async function buildNip98Token( + url: string, + method: string, + secretKeyHex?: string | null, +): Promise { + const unsignedEvent = { + kind: 27235, + tags: [ + ['u', url], + ['method', method.toUpperCase()], + ], + content: '', + created_at: Math.floor(Date.now() / 1000), + } + + let signedEvent: Record + + const signer = (window as { nostr?: NostrSigner }).nostr + if (signer) { + // Sign via NIP-07 extension or Amber + signedEvent = await signer.signEvent(unsignedEvent) + } else if (secretKeyHex) { + // Sign locally with secret key + const { hexToBytes } = await import('nostr-tools/utils') + const { finalizeEvent } = await import('nostr-tools') + const sk = hexToBytes(secretKeyHex) + const event = finalizeEvent(unsignedEvent, sk) + signedEvent = event as unknown as Record + } else { + throw new Error('No Nostr signer available. Install a NIP-07 extension or use a saved key.') + } + + return btoa(JSON.stringify(signedEvent)) +} + +// --------------------------------------------------------------------------- +// Authenticated Fetch +// --------------------------------------------------------------------------- + +/** + * Fetch wrapper that attaches the JWT Bearer token to requests. + * If the server returns 401, clears the stored token. + */ +export async function authFetch(url: string, init?: RequestInit): Promise { + const headers = new Headers(init?.headers) + if (currentToken && !isTokenExpired()) { + headers.set('Authorization', `Bearer ${currentToken}`) + } + const res = await fetch(url, { ...init, headers }) + if (res.status === 401) { + // Token rejected — clear it + setToken(null) + } + return res +} diff --git a/frontend/src/pages/JoinBoutPage.vue b/frontend/src/pages/JoinBoutPage.vue index e14c2e6..3dbbff9 100644 --- a/frontend/src/pages/JoinBoutPage.vue +++ b/frontend/src/pages/JoinBoutPage.vue @@ -7,6 +7,7 @@ import SpritePreview from '../components/SpritePreview.vue' import { ensureAudioContext } from '../game/audio' import HumanPreview from '../components/HumanPreview.vue' import WalletConnect from '../components/WalletConnect.vue' +import { authFetch } from '../lib/nostr-auth' const router = useRouter() const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, hasStoredKey, isLoading, login, generateLogin, loginWithNsec, registerBot, registerHuman, getStoredNsec, logout } = useNostr() @@ -184,6 +185,14 @@ async function handleLogin() { } } +function handleSignerLogin() { + if (!window.nostr) { + error.value = 'No Nostr signer detected. Install a NIP-07 extension (Nos2x, Alby) or use Amber on Android.' + return + } + handleLogin() +} + function handleGenerateLogin() { error.value = '' const { nsec } = generateLogin() @@ -432,7 +441,7 @@ async function fightRanked() { error.value = '' try { const paymentId = await payEntryFee(bot.value.id) - const res = await fetch(`/api/queue/join-ranked/${bot.value.id}`, { + const res = await authFetch(`/api/queue/join-ranked/${bot.value.id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paymentId, pubkey: pubkey.value }), @@ -540,7 +549,7 @@ function handleSignOut() {
- + + + + diff --git a/server/src/middleware/jwt.ts b/server/src/middleware/jwt.ts new file mode 100644 index 0000000..5c657b4 --- /dev/null +++ b/server/src/middleware/jwt.ts @@ -0,0 +1,67 @@ +import { createHmac, randomBytes } from 'crypto' +import { logger } from '../lib/logger.js' + +const JWT_SECRET = process.env.JWT_SECRET || randomBytes(32).toString('hex') +const JWT_EXPIRY = 24 * 60 * 60 // 24 hours + +if (!process.env.JWT_SECRET) { + logger.warn('jwt', 'JWT_SECRET not set — tokens will invalidate on server restart') +} + +interface JwtPayload { + sub: string // Nostr pubkey (hex) + botId?: string // Bot ID if registered + iat: number // Issued at + exp: number // Expiration +} + +function b64url(data: string | Buffer): string { + const buf = typeof data === 'string' ? Buffer.from(data) : data + return buf.toString('base64url') +} + +export function createJwt(pubkey: string, botId?: string): string { + const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const now = Math.floor(Date.now() / 1000) + const payload: JwtPayload = { + sub: pubkey, + ...(botId ? { botId } : {}), + iat: now, + exp: now + JWT_EXPIRY, + } + const payloadStr = b64url(JSON.stringify(payload)) + const signature = createHmac('sha256', JWT_SECRET) + .update(`${header}.${payloadStr}`) + .digest('base64url') + + return `${header}.${payloadStr}.${signature}` +} + +export function verifyJwt(token: string): JwtPayload | null { + try { + const parts = token.split('.') + if (parts.length !== 3) return null + + const [header, payload, signature] = parts + const expectedSig = createHmac('sha256', JWT_SECRET) + .update(`${header}.${payload}`) + .digest('base64url') + + if (signature !== expectedSig) return null + + const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString()) as JwtPayload + const now = Math.floor(Date.now() / 1000) + if (decoded.exp && decoded.exp <= now) return null + + return decoded + } catch { + return null + } +} + +/** Extract pubkey from JWT Bearer token in Authorization header, or return null */ +export function extractPubkeyFromAuth(authHeader: string | undefined): string | null { + if (!authHeader?.startsWith('Bearer ')) return null + const payload = verifyJwt(authHeader.slice(7)) + return payload?.sub || null +} diff --git a/server/src/middleware/nip98.ts b/server/src/middleware/nip98.ts new file mode 100644 index 0000000..333ad45 --- /dev/null +++ b/server/src/middleware/nip98.ts @@ -0,0 +1,78 @@ +import { verifyEvent } from 'nostr-tools' + +interface Nip98Result { + valid: boolean + pubkey?: string + error?: string +} + +/** + * Verify a NIP-98 HTTP Auth event from the Authorization or nostr-authorization header. + * Expects format: "Nostr " + * + * Validates: + * - Event kind is 27235 + * - URL path matches the request path + * - HTTP method matches + * - created_at is within 120 seconds of now + * - Schnorr signature is valid + */ +export function verifyNip98Token( + authHeader: string, + requestPath: string, + requestMethod: string, +): Nip98Result { + try { + const match = authHeader.match(/^Nostr\s+(.+)$/i) + if (!match) return { valid: false, error: 'Invalid auth header format' } + + let eventJson: string + try { + eventJson = Buffer.from(match[1], 'base64').toString('utf-8') + } catch { + return { valid: false, error: 'Invalid base64 encoding' } + } + + const event = JSON.parse(eventJson) + + // Verify kind 27235 + if (event.kind !== 27235) { + return { valid: false, error: 'Wrong event kind (expected 27235)' } + } + + // Verify URL tag — compare path component only (works behind proxies) + const urlTag = event.tags?.find((t: string[]) => t[0] === 'u') + if (!urlTag || !urlTag[1]) { + return { valid: false, error: 'Missing URL tag' } + } + try { + const eventPath = new URL(urlTag[1]).pathname + if (eventPath !== requestPath) { + return { valid: false, error: `URL path mismatch: ${eventPath} !== ${requestPath}` } + } + } catch { + return { valid: false, error: 'Invalid URL in event tag' } + } + + // Verify method tag + const methodTag = event.tags?.find((t: string[]) => t[0] === 'method') + if (!methodTag || methodTag[1].toUpperCase() !== requestMethod.toUpperCase()) { + return { valid: false, error: 'Method mismatch' } + } + + // Verify created_at is recent (within 120 seconds) + const now = Math.floor(Date.now() / 1000) + if (Math.abs(now - event.created_at) > 120) { + return { valid: false, error: 'Event expired or clock drift too large' } + } + + // Verify schnorr signature + if (!verifyEvent(event)) { + return { valid: false, error: 'Invalid signature' } + } + + return { valid: true, pubkey: event.pubkey } + } catch (err) { + return { valid: false, error: 'Failed to parse NIP-98 token' } + } +} diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index ccd52cb..198c286 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -361,3 +361,130 @@ authRouter.post('/update', rateLimit(60_000, 10), async (c) => { return c.json({ updated: true }) }) + +// --- NIP-98 Authenticated Session --- +import { verifyNip98Token } from '../middleware/nip98.js' +import { createJwt, extractPubkeyFromAuth } from '../middleware/jwt.js' + +// POST /nostr/session — authenticate with NIP-98, receive JWT +authRouter.post('/nostr/session', rateLimit(60_000, 30), async (c) => { + // Extract NIP-98 token from headers (try multiple header names) + const authHeader = c.req.header('Authorization') + || c.req.header('nostr-authorization') + || c.req.header('x-nostr-authorization') + + if (!authHeader) { + return c.json({ error: 'Missing NIP-98 authorization header' }, 401) + } + + // Verify the NIP-98 event signature, URL, method, and freshness + const requestPath = new URL(c.req.url).pathname + const result = verifyNip98Token(authHeader, requestPath, 'POST') + + if (!result.valid || !result.pubkey) { + return c.json({ error: result.error || 'NIP-98 verification failed' }, 401) + } + + const pubkey = result.pubkey + + // Look up bot for this pubkey + const rows = await db.select({ + id: schema.bots.id, + name: schema.bots.name, + avatarSeed: schema.bots.avatarSeed, + archetype: schema.bots.archetype, + profilePicUrl: schema.bots.profilePicUrl, + eloRating: schema.bots.eloRating, + wins: schema.bots.wins, + losses: schema.bots.losses, + winStreak: schema.bots.winStreak, + bestStreak: schema.bots.bestStreak, + tier: schema.bots.tier, + isActive: schema.bots.isActive, + customization: schema.bots.customization, + webhookUrl: schema.bots.webhookUrl, + satsWon: schema.bots.satsWon, + satsWagered: schema.bots.satsWagered, + }).from(schema.bots).where(eq(schema.bots.publicKey, pubkey)).limit(1) + + let botData = null + let botId: string | undefined + + if (rows.length > 0) { + const bot = rows[0] + botId = bot.id + const isHuman = bot.webhookUrl === 'http://human.local/' + + // Auto-upgrade creator archetype + if (pubkey === CREATOR_PUBKEY && bot.archetype !== "the_creator") { + await db.update(schema.bots).set({ archetype: "the_creator" }).where(eq(schema.bots.id, bot.id)) + bot.archetype = "the_creator" + } + + botData = { + id: bot.id, + name: bot.name, + avatarSeed: bot.avatarSeed, + archetype: bot.archetype, + profilePicUrl: bot.profilePicUrl, + eloRating: bot.eloRating, + wins: bot.wins, + losses: bot.losses, + winStreak: bot.winStreak, + bestStreak: bot.bestStreak, + tier: bot.tier, + isActive: bot.isActive, + isHuman, + customization: bot.customization ? JSON.parse(bot.customization) : null, + satsWon: bot.satsWon ?? 0, + satsWagered: bot.satsWagered ?? 0, + hasWallet: false, + } + } else if (pubkey === CREATOR_PUBKEY) { + // Auto-create creator + const id = nanoid(12) + const secret = randomBytes(32).toString('hex') + await db.insert(schema.bots).values({ + id, + name: 'the_creator', + webhookUrl: 'http://human.local/', + avatarSeed: 'the_creator', + archetype: 'the_creator', + secretHash: createHash('sha256').update(secret).digest('hex'), + publicKey: pubkey, + profilePicUrl: null, + customization: null, + createdAt: new Date().toISOString(), + }) + botId = id + botData = { + id, + name: 'the_creator', + avatarSeed: 'the_creator', + archetype: 'the_creator', + profilePicUrl: null, + eloRating: 1200, + wins: 0, + losses: 0, + winStreak: 0, + bestStreak: 0, + tier: 0, + isActive: true, + isHuman: true, + customization: null, + satsWon: 0, + satsWagered: 0, + hasWallet: false, + } + } + + // Issue JWT (valid for 24 hours) + const token = createJwt(pubkey, botId) + + return c.json({ + token, + exists: !!botData, + pubkey, + bot: botData, + }) +})