import { Request, Response, NextFunction } from "express"; import { verifyEvent, getEventHash } from "nostr-tools"; import { config } from "../config.js"; import { getDb } from "../db/index.js"; const AUTH_SCHEME = "Nostr "; export interface NostrAuthPayload { pubkey: string; eventId: string; method?: "nip98" | "npub"; } declare global { namespace Express { interface Request { nostr?: NostrAuthPayload; } } } function getTag(event: { tags: string[][] }, name: string): string | null { const row = event.tags.find((t) => t[0] === name); return row && row[1] ? row[1] : null; } export async function nip98Auth(req: Request, res: Response, next: NextFunction): Promise { const auth = req.headers.authorization; if (!auth || !auth.startsWith(AUTH_SCHEME)) { res.status(401).json({ code: "invalid_nip98", message: "Missing or invalid Authorization header. Use NIP-98 Nostr scheme.", }); return; } const base64 = auth.slice(AUTH_SCHEME.length).trim(); let event: { id: string; pubkey: string; kind: number; created_at: number; tags: string[][]; content: string; sig: string }; try { const decoded = Buffer.from(base64, "base64").toString("utf-8"); const parsed = JSON.parse(decoded) as Record; event = { id: String(parsed.id ?? ""), pubkey: String(parsed.pubkey ?? ""), kind: Number(parsed.kind), created_at: Number(parsed.created_at), tags: Array.isArray(parsed.tags) ? (parsed.tags as string[][]) : [], content: typeof parsed.content === "string" ? parsed.content : "", sig: String(parsed.sig ?? ""), }; } catch { res.status(401).json({ code: "invalid_nip98", message: "Invalid NIP-98 payload: not valid base64 or JSON.", }); return; } if (event.kind !== 27235) { res.status(401).json({ code: "invalid_nip98", message: "NIP-98 event kind must be 27235.", }); return; } const now = Math.floor(Date.now() / 1000); if (Math.abs(event.created_at - now) > config.nip98MaxSkewSeconds) { res.status(401).json({ code: "invalid_nip98", message: "NIP-98 event timestamp is outside allowed window.", }); return; } const u = getTag(event, "u"); const method = getTag(event, "method"); if (!u || !method) { res.status(401).json({ code: "invalid_nip98", message: "NIP-98 event must include 'u' and 'method' tags.", }); return; } // Reconstruct absolute URL (protocol + host + path + query) const proto = (req.headers["x-forwarded-proto"] as string | undefined) ?? ((req.socket as { encrypted?: boolean }).encrypted ? "https" : "http"); const host = (req.headers["x-forwarded-host"] as string | undefined) ?? req.headers.host ?? ""; const path = req.originalUrl ?? req.url; const absoluteUrl = `${proto}://${host}${path}`; if (u !== absoluteUrl) { res.status(401).json({ code: "invalid_nip98", message: "NIP-98 'u' tag does not match request URL.", }); return; } if (method.toUpperCase() !== req.method) { res.status(401).json({ code: "invalid_nip98", message: "NIP-98 'method' tag does not match request method.", }); return; } const computedId = getEventHash({ kind: event.kind, pubkey: event.pubkey, created_at: event.created_at, tags: event.tags, content: event.content, sig: event.sig, } as Parameters[0]); if (computedId !== event.id) { res.status(401).json({ code: "invalid_nip98", message: "NIP-98 event id does not match computed hash.", }); return; } const valid = verifyEvent({ id: event.id, kind: event.kind, pubkey: event.pubkey, created_at: event.created_at, tags: event.tags, content: event.content, sig: event.sig, } as Parameters[0]); if (!valid) { res.status(401).json({ code: "invalid_nip98", message: "NIP-98 signature verification failed.", }); return; } const db = getDb(); const hasNonce = await db.hasNonce(event.id); if (hasNonce) { res.status(401).json({ code: "invalid_nip98", message: "NIP-98 nonce already used (replay).", }); return; } const expiresAt = now + config.nonceTtlSeconds; await db.setNonce(event.id, expiresAt); req.nostr = { pubkey: event.pubkey, eventId: event.id }; next(); }