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:
co-authored by
Claude Opus 4.6
parent
ad96d1158f
commit
3ba05a66b4
@@ -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
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user