// Bot authentication helper. // Verifies bot identity via bot_id + secret (SHA256 hash comparison). // Supports: Authorization header or query params. import { createHash, timingSafeEqual } from 'crypto' import { db, schema } from '../db/index.js' import { eq } from 'drizzle-orm' import type { Context } from 'hono' import { extractPubkeyFromAuth } from './jwt.js' export interface BotAuthContext { botId: string botName: string webhookUrl: string } /** Extract and verify bot credentials from request. Returns bot context or error response. */ export async function authenticateBot(c: Context): Promise { let botId: string | undefined let secret: string | undefined // 1. Authorization: Bot : const auth = c.req.header('Authorization') if (auth?.startsWith('Bot ')) { const parts = auth.slice(4).split(':') if (parts.length === 2 && parts[0] && parts[1]) { botId = parts[0] secret = parts[1] } } // 2. Query params: ?bot_id=xxx&secret=yyy if (!botId || !secret) { const qBotId = c.req.query('bot_id') const qSecret = c.req.query('secret') if (qBotId && qSecret) { botId = qBotId secret = qSecret } } if (!botId || !secret) { return c.json({ error: 'Authentication required. Use Authorization: Bot : or query params ?bot_id=...&secret=...' }, 401) } const hash = createHash('sha256').update(secret).digest('hex') const rows = await db.select({ id: schema.bots.id, name: schema.bots.name, secretHash: schema.bots.secretHash, webhookUrl: schema.bots.webhookUrl, }).from(schema.bots).where(eq(schema.bots.id, botId)).limit(1) if (rows.length === 0) { return c.json({ error: 'Invalid bot_id or secret.' }, 401) } // Constant-time comparison using Node's native timingSafeEqual const expected = rows[0].secretHash if (hash.length !== expected.length || !timingSafeEqual(Buffer.from(hash), Buffer.from(expected))) { return c.json({ error: 'Invalid bot_id or secret.' }, 401) } return { botId: rows[0].id, botName: rows[0].name, webhookUrl: rows[0].webhookUrl, } } /** * Verify the caller owns `botId`, for routes that must accept BOTH audiences: * nostr-signed-in owners (web UI's JWT session) and anonymous poll-mode bots * (Authorization: Bot :, which never have a publicKey — see * BOTFIGHTS.md). This is the ONLY correct way to check the nostr side: it * derives pubkey from a verified JWT (extractPubkeyFromAuth), never from a * client-supplied `pubkey` field. A bare `body.pubkey === bot.publicKey` * comparison is not an ownership check at all — pubkeys are public by * design in nostr (shown on every bot's own profile page), so anyone who's * viewed a bot's page could pass that same auth-check with zero secret * material. (This exact bug, at POST /api/auth/update, was found and fixed * in 09-06 — see auth.ts. Same class, same fix, applied everywhere ownership * is checked by pubkey.) */ export async function verifyBotOwner(c: Context, botId: string): Promise { const auth = c.req.header('Authorization') if (auth?.startsWith('Bearer ')) { const pubkey = extractPubkeyFromAuth(auth) if (!pubkey) { return c.json({ error: 'Invalid or expired session.' }, 401) } const rows = await db.select({ publicKey: schema.bots.publicKey }) .from(schema.bots).where(eq(schema.bots.id, botId)).limit(1) if (rows.length === 0 || rows[0].publicKey !== pubkey) { return c.json({ error: 'Unauthorized' }, 403) } return true } const botOrRes = await authenticateBot(c) if (botOrRes instanceof Response) return botOrRes if (botOrRes.botId !== botId) { return c.json({ error: 'Unauthorized' }, 403) } return true }