Files
regress/server/src/services/nip98.ts
T
ssmithx d6ef514c84 Scaffold Regress: Fastify+SQLite server and Vue frontend for the Ingress-style BTC Map capture game
- Server: NIP-98 nostr auth (session cookies), BTC Map sync, claim/link/field
  routes, physical-presence checks via geolocation distance. 31 tests passing.
- Frontend: Leaflet map, team pick, claim/link UI, Archipelago identity
  bridge vendored (nostr-provider.js) for dashboard launch support.
- Verified end-to-end against the live BTC Map API (167 real Madeira places)
  and via genuine NIP-98-signed HTTP requests against the built app.
2026-08-05 13:14:03 +00:00

72 lines
2.4 KiB
TypeScript

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 <base64 event>` 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;
}