import { createHash } from 'node:crypto'; import { verifyEvent, type Event } from 'nostr-tools/pure'; export const NIP98_KIND = 27235; export class Nip98Error extends Error {} export interface Nip98Options { /** Full URLs the signed `u` tag is allowed to match (same request via different hosts). */ allowedUrls: string[]; method: string; body?: Buffer | null; maxSkewSecs: number; now?: number; /** Returns true if the event id was already used (replay). */ isReplay?: (eventId: string) => boolean; } function tag(event: Event, name: string): string | undefined { return event.tags.find((t) => t[0] === name)?.[1]; } function normalizeUrl(u: string): string { try { const url = new URL(u); return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, '') || '/'}${url.search}`; } catch { return u; } } /** Verify a NIP-98 `Authorization: Nostr ` header. Returns the signer pubkey. */ export function verifyNip98(header: string | undefined, opts: Nip98Options): string { if (!header?.startsWith('Nostr ')) throw new Nip98Error('missing Nostr authorization header'); let event: Event; try { event = JSON.parse(Buffer.from(header.slice(6).trim(), 'base64').toString('utf8')); } catch { throw new Nip98Error('malformed authorization event'); } if (event.kind !== NIP98_KIND) throw new Nip98Error(`wrong event kind (expected ${NIP98_KIND})`); const now = opts.now ?? Math.floor(Date.now() / 1000); if (Math.abs(now - event.created_at) > opts.maxSkewSecs) { throw new Nip98Error('authorization event expired or clock skew too large — check your clock'); } const u = tag(event, 'u'); if (!u || !opts.allowedUrls.some((a) => normalizeUrl(a) === normalizeUrl(u))) { throw new Nip98Error('u tag does not match the request URL'); } const method = tag(event, 'method'); if (!method || method.toUpperCase() !== opts.method.toUpperCase()) { throw new Nip98Error('method tag does not match the request method'); } if (opts.body && opts.body.length > 0) { const payload = tag(event, 'payload'); const digest = createHash('sha256').update(opts.body).digest('hex'); if (payload && payload !== digest) throw new Nip98Error('payload hash mismatch'); } if (!verifyEvent(event)) throw new Nip98Error('invalid event signature'); if (opts.isReplay?.(event.id)) throw new Nip98Error('authorization event already used'); return event.pubkey; }