2026-03-09 18:34:22 +00:00
|
|
|
// Bot authentication helper.
|
|
|
|
|
// Verifies bot identity via bot_id + secret (SHA256 hash comparison).
|
|
|
|
|
// Supports: Authorization header or query params.
|
|
|
|
|
|
2026-03-13 09:26:39 +00:00
|
|
|
import { createHash, timingSafeEqual } from 'crypto'
|
2026-03-09 18:34:22 +00:00
|
|
|
import { db, schema } from '../db/index.js'
|
|
|
|
|
import { eq } from 'drizzle-orm'
|
|
|
|
|
import type { Context } from 'hono'
|
2026-07-31 13:00:08 -04:00
|
|
|
import { extractPubkeyFromAuth } from './jwt.js'
|
2026-03-09 18:34:22 +00:00
|
|
|
|
|
|
|
|
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<BotAuthContext | Response> {
|
|
|
|
|
let botId: string | undefined
|
|
|
|
|
let secret: string | undefined
|
|
|
|
|
|
|
|
|
|
// 1. Authorization: Bot <bot_id>:<secret>
|
|
|
|
|
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 <bot_id>:<secret> 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 09:26:39 +00:00
|
|
|
// Constant-time comparison using Node's native timingSafeEqual
|
2026-03-09 18:34:22 +00:00
|
|
|
const expected = rows[0].secretHash
|
2026-03-13 09:26:39 +00:00
|
|
|
if (hash.length !== expected.length ||
|
|
|
|
|
!timingSafeEqual(Buffer.from(hash), Buffer.from(expected))) {
|
2026-03-09 18:34:22 +00:00
|
|
|
return c.json({ error: 'Invalid bot_id or secret.' }, 401)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
botId: rows[0].id,
|
|
|
|
|
botName: rows[0].name,
|
|
|
|
|
webhookUrl: rows[0].webhookUrl,
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-31 13:00:08 -04:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 <id>:<secret>, 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<true | Response> {
|
|
|
|
|
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
|
|
|
|
|
}
|