feat: NIP-98 + JWT authentication with signer support

Replace insecure raw-pubkey auth with cryptographic NIP-98 signed
requests and server-issued JWT sessions. Logout now fully clears
all state including nsec. Add yellow "Use Nostr Signer" button
for Amber/NIP-07 remote signers.

- Server: JWT middleware (HMAC-SHA256, 24h expiry), NIP-98 verification
- Server: POST /api/auth/nostr/session endpoint
- Frontend: NIP-98 token builder + authFetch wrapper with JWT Bearer
- Frontend: All authenticated API calls use authFetch
- Security: logout clears JWT, pubkey, bot, nsec, and profile pic

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-09 12:31:25 +00:00
co-authored by Claude Opus 4.6
parent ad96d1158f
commit 3ba05a66b4
8 changed files with 532 additions and 105 deletions
+2 -1
View File
@@ -2,6 +2,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useWallet } from '../composables/useWallet' import { useWallet } from '../composables/useWallet'
import { useNostr } from '../composables/useNostr' import { useNostr } from '../composables/useNostr'
import { authFetch } from '../lib/nostr-auth'
const props = defineProps<{ const props = defineProps<{
fightId: string fightId: string
@@ -65,7 +66,7 @@ async function placeBet() {
body.cashuToken = cashuToken.value.trim() body.cashuToken = cashuToken.value.trim()
} }
const res = await fetch('/api/bets/place', { const res = await authFetch('/api/bets/place', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
+115 -93
View File
@@ -1,7 +1,8 @@
import { ref, readonly, computed } from 'vue' 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 { bytesToHex, hexToBytes } from 'nostr-tools/utils'
import { nsecEncode } from 'nostr-tools/nip19' import { nsecEncode } from 'nostr-tools/nip19'
import { buildNip98Token, setToken, getToken, isTokenExpired, authFetch } from '../lib/nostr-auth'
interface BotCustomization { interface BotCustomization {
archetype?: string archetype?: string
@@ -65,7 +66,7 @@ function normalizeBotData(data: Record<string, unknown>): BotData {
} as BotData } as BotData
} }
/** Clear all auth-related state */ /** Clear all auth-related state including JWT and nsec */
function clearAllState() { function clearAllState() {
pubkey.value = null pubkey.value = null
bot.value = null bot.value = null
@@ -73,6 +74,8 @@ function clearAllState() {
store('bf_pubkey', null) store('bf_pubkey', null)
store('bf_bot', null) store('bf_bot', null)
store('bf_pic', null) store('bf_pic', null)
setToken(null)
localStorage.removeItem('bf_nsec')
} }
const pubkey = ref<string | null>(loadStored('bf_pubkey')) const pubkey = ref<string | null>(loadStored('bf_pubkey'))
@@ -85,89 +88,109 @@ let autoRestoreRan = false
// Flag: skip relay pic fetch for freshly generated keys (no profile exists) // Flag: skip relay pic fetch for freshly generated keys (no profile exists)
let freshlyGenerated = false 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() { export function useNostr() {
const isLoggedIn = computed(() => !!pubkey.value && !!bot.value) const isLoggedIn = computed(() => !!pubkey.value && !!bot.value)
const hasExtension = computed(() => !!window.nostr) const hasExtension = computed(() => !!window.nostr)
// Restore session on first load — re-verify with server (once only) /**
const autoRestoreController = new AbortController() * Authenticate with the server using NIP-98 signed request.
if (!autoRestoreRan && pubkey.value && !bot.value) { * Works with NIP-07 extension, Amber signer, or local nsec.
autoRestoreRan = true * Returns JWT session token + bot data.
fetch('/api/auth/login', { */
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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
body: JSON.stringify({ pubkey: pubkey.value }), 'Authorization': `Nostr ${nip98Token}`,
signal: autoRestoreController.signal, },
}).then(r => r.json()).then(data => { })
if (data.exists) {
bot.value = normalizeBotData(data.bot) if (!res.ok) {
store('bf_bot', bot.value) const data = await res.json().catch(() => ({ error: 'Authentication failed' }))
} throw new Error(data.error || 'NIP-98 authentication failed')
}).catch(() => {}) }
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 }> { async function login(): Promise<{ pubkey: string; bot: BotData | null }> {
let pk: string if (!window.nostr) {
// Fall back to stored nsec if available
if (window.nostr) {
try {
pk = await window.nostr.getPublicKey()
} catch {
throw new Error('Nostr extension denied access. Approve the request and try again.')
}
} else {
const storedNsec = localStorage.getItem('bf_nsec') const storedNsec = localStorage.getItem('bf_nsec')
if (storedNsec) { if (storedNsec) {
try { return loginWithNsec(storedNsec)
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.')
} }
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 isLoading.value = true
try { try {
pubkey.value = pk const result = await authenticateSession()
store('bf_pubkey', pk)
// Fetch Nostr profile pic from relay (skip for freshly generated keys — no profile exists) // Fetch Nostr profile pic (non-blocking)
if (freshlyGenerated) { if (!freshlyGenerated) {
freshlyGenerated = false fetchNostrProfilePic(result.pubkey).then(pic => {
profilePicUrl.value = null
store('bf_pic', null)
} else {
// Non-blocking: start fetch but don't block login on it
fetchNostrProfilePic(pk).then(pic => {
profilePicUrl.value = pic profilePicUrl.value = pic
store('bf_pic', pic) store('bf_pic', pic)
}).catch(() => {}) }).catch(() => {})
} else {
freshlyGenerated = false
profilePicUrl.value = null
store('bf_pic', null)
} }
// Check if this pubkey has a bot return result
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 }
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@@ -186,7 +209,7 @@ export function useNostr() {
// Mark as freshly generated so login() skips relay pic fetch // Mark as freshly generated so login() skips relay pic fetch
freshlyGenerated = true freshlyGenerated = true
// Store new key // Store new key (will be cleared on logout)
localStorage.setItem('bf_nsec', nsecHex) localStorage.setItem('bf_nsec', nsecHex)
pubkey.value = pk pubkey.value = pk
store('bf_pubkey', pk) store('bf_pubkey', pk)
@@ -194,7 +217,7 @@ export function useNostr() {
return { pubkey: pk, nsec: nsecBech32 } 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 }> { async function loginWithNsec(nsecHex: string): Promise<{ pubkey: string; bot: BotData | null }> {
let secretKey: Uint8Array let secretKey: Uint8Array
try { try {
@@ -202,7 +225,7 @@ export function useNostr() {
} catch { } catch {
throw new Error('Invalid secret key format.') throw new Error('Invalid secret key format.')
} }
const pk = getPublicKey(secretKey) getPublicKey(secretKey) // validate key
// Clear stale state before switching identity // Clear stale state before switching identity
bot.value = null bot.value = null
@@ -211,33 +234,18 @@ export function useNostr() {
store('bf_pic', null) store('bf_pic', null)
localStorage.setItem('bf_nsec', nsecHex) localStorage.setItem('bf_nsec', nsecHex)
pubkey.value = pk
store('bf_pubkey', pk)
isLoading.value = true isLoading.value = true
try { try {
const result = await authenticateSession(nsecHex)
// Fetch Nostr profile pic (non-blocking) // Fetch Nostr profile pic (non-blocking)
fetchNostrProfilePic(pk).then(pic => { fetchNostrProfilePic(result.pubkey).then(pic => {
profilePicUrl.value = pic profilePicUrl.value = pic
store('bf_pic', pic) store('bf_pic', pic)
}).catch(() => {}) }).catch(() => {})
const res = await fetch('/api/auth/login', { return result
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 }
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@@ -246,7 +254,7 @@ export function useNostr() {
async function registerBot(name: string, webhookUrl: string, archetype: string): Promise<BotData> { async function registerBot(name: string, webhookUrl: string, archetype: string): Promise<BotData> {
if (!pubkey.value) throw new Error('Not logged in') 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -260,7 +268,6 @@ export function useNostr() {
const data = await res.json() const data = await res.json()
if (!res.ok) { if (!res.ok) {
// Include retry timer info for rate limits
let msg = data.details ? `${data.error} ${data.details}` : (data.error || 'Registration failed') let msg = data.details ? `${data.error} ${data.details}` : (data.error || 'Registration failed')
if (res.status === 429 && data.retryAfterSec) msg += ` (${data.retryAfterSec}s)` if (res.status === 429 && data.retryAfterSec) msg += ` (${data.retryAfterSec}s)`
throw new Error(msg) throw new Error(msg)
@@ -285,13 +292,21 @@ export function useNostr() {
} }
store('bf_bot', bot.value) 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 return bot.value
} }
async function updateCustomization(customization: BotCustomization): Promise<void> { async function updateCustomization(customization: BotCustomization): Promise<void> {
if (!pubkey.value) throw new Error('Not logged in') 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pubkey: pubkey.value, customization }), body: JSON.stringify({ pubkey: pubkey.value, customization }),
@@ -315,7 +330,7 @@ export function useNostr() {
async function updateWebhook(newUrl: string): Promise<{ latencyMs: number }> { async function updateWebhook(newUrl: string): Promise<{ latencyMs: number }> {
if (!pubkey.value) throw new Error('Not logged in') 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pubkey: pubkey.value, webhookUrl: newUrl }), body: JSON.stringify({ pubkey: pubkey.value, webhookUrl: newUrl }),
@@ -333,7 +348,7 @@ export function useNostr() {
async function registerHuman(name: string, avatarSeed?: string): Promise<BotData> { async function registerHuman(name: string, avatarSeed?: string): Promise<BotData> {
if (!pubkey.value) throw new Error('Not logged in') 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -371,13 +386,20 @@ export function useNostr() {
} }
store('bf_bot', bot.value) 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 return bot.value
} }
/** Sign out — clears everything including JWT, nsec, and all stored state */
function logout() { function logout() {
autoRestoreController.abort()
clearAllState() 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) */ /** Check if user has a locally stored key (no extension needed) */
+9 -8
View File
@@ -4,6 +4,7 @@ import { finalizeEvent } from 'nostr-tools'
import * as nip04 from 'nostr-tools/nip04' import * as nip04 from 'nostr-tools/nip04'
import * as nip44 from 'nostr-tools/nip44' import * as nip44 from 'nostr-tools/nip44'
import { hexToBytes, bytesToHex } from 'nostr-tools/utils' import { hexToBytes, bytesToHex } from 'nostr-tools/utils'
import { authFetch } from '../lib/nostr-auth'
type WalletMethod = 'nwc' | 'lnaddress' | null type WalletMethod = 'nwc' | 'lnaddress' | null
type PaymentStatus = 'idle' | 'invoiced' | 'paying' | 'confirmed' | 'failed' type PaymentStatus = 'idle' | 'invoiced' | 'paying' | 'confirmed' | 'failed'
@@ -59,7 +60,7 @@ export function useWallet() {
// Validate the NWC URL format // Validate the NWC URL format
parseNwcUrl(connectionString) parseNwcUrl(connectionString)
const res = await fetch('/api/payments/connect-wallet', { const res = await authFetch('/api/payments/connect-wallet', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -88,7 +89,7 @@ export function useWallet() {
throw new Error('Invalid Lightning Address format') 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -111,7 +112,7 @@ export function useWallet() {
async function disconnectWallet(): Promise<void> { async function disconnectWallet(): Promise<void> {
if (!pubkey.value) return if (!pubkey.value) return
await fetch('/api/payments/disconnect-wallet', { await authFetch('/api/payments/disconnect-wallet', {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pubkey: pubkey.value }), body: JSON.stringify({ pubkey: pubkey.value }),
@@ -128,7 +129,7 @@ export function useWallet() {
async function checkWalletStatus(): Promise<void> { async function checkWalletStatus(): Promise<void> {
if (!pubkey.value) return 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) { if (res.ok) {
const data = await res.json() const data = await res.json()
isWalletConnected.value = data.connected isWalletConnected.value = data.connected
@@ -144,7 +145,7 @@ export function useWallet() {
try { try {
// Create invoice // Create invoice
const invoiceRes = await fetch('/api/payments/create-invoice', { const invoiceRes = await authFetch('/api/payments/create-invoice', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ botId, pubkey: pubkey.value }), body: JSON.stringify({ botId, pubkey: pubkey.value }),
@@ -175,7 +176,7 @@ export function useWallet() {
const preimage = await payViaNWC(nwcUrl, bolt11) const preimage = await payViaNWC(nwcUrl, bolt11)
// Tell server payment is confirmed (skip lookup_invoice polling) // Tell server payment is confirmed (skip lookup_invoice polling)
await fetch(`/api/payments/confirm/${paymentId}`, { await authFetch(`/api/payments/confirm/${paymentId}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preimage, pubkey: pubkey.value }), body: JSON.stringify({ preimage, pubkey: pubkey.value }),
@@ -187,7 +188,7 @@ export function useWallet() {
// No NWC — poll for confirmation (manual payment / QR code flow) // No NWC — poll for confirmation (manual payment / QR code flow)
for (let i = 0; i < 30; i++) { for (let i = 0; i < 30; i++) {
await new Promise(r => setTimeout(r, 2000)) 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) { if (checkRes.ok) {
const { status } = await checkRes.json() const { status } = await checkRes.json()
if (status === 'confirmed') { if (status === 'confirmed') {
@@ -212,7 +213,7 @@ export function useWallet() {
} }
async function submitCashuToken(botId: string, token: string): Promise<string> { async function submitCashuToken(botId: string, token: string): Promise<string> {
const res = await fetch('/api/payments/submit-cashu', { const res = await authFetch('/api/payments/submit-cashu', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ botId, token }), body: JSON.stringify({ botId, token }),
+108
View File
@@ -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<string>
signEvent(event: Record<string, unknown>): Promise<Record<string, unknown>>
}
/**
* 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<string> {
const unsignedEvent = {
kind: 27235,
tags: [
['u', url],
['method', method.toUpperCase()],
],
content: '',
created_at: Math.floor(Date.now() / 1000),
}
let signedEvent: Record<string, unknown>
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<string, unknown>
} 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<Response> {
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
}
+26 -3
View File
@@ -7,6 +7,7 @@ import SpritePreview from '../components/SpritePreview.vue'
import { ensureAudioContext } from '../game/audio' import { ensureAudioContext } from '../game/audio'
import HumanPreview from '../components/HumanPreview.vue' import HumanPreview from '../components/HumanPreview.vue'
import WalletConnect from '../components/WalletConnect.vue' import WalletConnect from '../components/WalletConnect.vue'
import { authFetch } from '../lib/nostr-auth'
const router = useRouter() const router = useRouter()
const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, hasStoredKey, isLoading, login, generateLogin, loginWithNsec, registerBot, registerHuman, getStoredNsec, logout } = useNostr() 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() { function handleGenerateLogin() {
error.value = '' error.value = ''
const { nsec } = generateLogin() const { nsec } = generateLogin()
@@ -432,7 +441,7 @@ async function fightRanked() {
error.value = '' error.value = ''
try { try {
const paymentId = await payEntryFee(bot.value.id) 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paymentId, pubkey: pubkey.value }), body: JSON.stringify({ paymentId, pubkey: pubkey.value }),
@@ -540,7 +549,7 @@ function handleSignOut() {
</div> </div>
<div v-if="!showNsecBackup" class="space-y-3"> <div v-if="!showNsecBackup" class="space-y-3">
<!-- Sign in with extension --> <!-- Sign in with extension (NIP-07) -->
<button <button
v-if="hasExtension" v-if="hasExtension"
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
@@ -552,7 +561,21 @@ function handleSignOut() {
@click="handleLogin" @click="handleLogin"
> >
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-purple/30 border-t-neon-purple rounded-full animate-spin" /> <span v-if="isLoading" class="w-5 h-5 border-2 border-neon-purple/30 border-t-neon-purple rounded-full animate-spin" />
{{ isLoading ? 'CONNECTING...' : 'SIGN IN WITH NOSTR' }} {{ isLoading ? 'CONNECTING...' : 'SIGN IN WITH EXTENSION' }}
</button>
<!-- Sign in with Nostr signer -->
<button
class="w-full py-4 bg-neon-yellow/10 border-2 border-neon-yellow/50 text-neon-yellow
font-display font-black text-base tracking-widest
hover:bg-neon-yellow/20 hover:border-neon-yellow transition-all
disabled:opacity-50 disabled:cursor-wait
flex items-center justify-center gap-3"
:disabled="isLoading"
@click="handleSignerLogin"
>
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-yellow/30 border-t-neon-yellow rounded-full animate-spin" />
{{ isLoading ? 'CONNECTING...' : 'USE NOSTR SIGNER' }}
</button> </button>
<!-- Sign in with stored key --> <!-- Sign in with stored key -->
+67
View File
@@ -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
}
+78
View File
@@ -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 <base64-encoded-signed-event>"
*
* 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' }
}
}
+127
View File
@@ -361,3 +361,130 @@ authRouter.post('/update', rateLimit(60_000, 10), async (c) => {
return c.json({ updated: true }) 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,
})
})