- 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.
72 lines
2.4 KiB
TypeScript
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;
|
|
}
|