clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

main
bindws / src / auth.ts
auth.ts 4.6K
1// Who is asking over HTTP. Two token shapes arrive in the Authorization2// header: NIP-98 (kind 27235, signed over this URL, method and body) for3// management, the bridge, dumps and views, and Blossom (kind 24242, a t tag4// naming the action) for files. whoAsks accepts either, so every read door5// can learn the caller's pubkeys the same way and hand them to the one read6// gate, Settings.mayRead. Nothing here decides access; it only identifies.7import { sha256 } from "@noble/hashes/sha2.js";8import { now, tagValues, validate, type Event } from "./event.ts";9import { bytesToHex } from "./negentropy.ts";1011export type BlossomAction = "get" | "list" | "upload" | "delete";1213// verifyNIP98 checks an "Authorization: Nostr <base64 event>" header against14// the request: kind 27235, fresh, u tag naming this URL, method tag, and a15// payload tag equal to the SHA-256 of the exact body. Returns the event or16// the reason it was rejected.17export function verifyNIP98(header: string, url: string, method: string, body: string): Event | string {18 const m = /^Nostr\s+(\S+)$/i.exec(header.trim());19 if (!m) return "auth-required: missing NIP-98 Authorization header";20 let e: Event;21 try {22 e = JSON.parse(atob(m[1]));23 } catch {24 return "auth-required: malformed NIP-98 token";25 }26 const bad = validate(e);27 if (bad) return "auth-required: " + bad;28 if (e.kind !== 27235) return "auth-required: token must be kind 27235";29 if (Math.abs(now() - e.created_at) > 60) return "auth-required: token expired";30 const want = new URL(url);31 const got = (() => {32 try {33 return new URL(tagValues(e, "u")[0] ?? "");34 } catch {35 return null;36 }37 })();38 if (!got || got.host.toLowerCase() !== want.host.toLowerCase() || got.pathname !== want.pathname) return "auth-required: token was signed for another URL";39 if ((tagValues(e, "method")[0] ?? "").toUpperCase() !== method.toUpperCase()) return "auth-required: token was signed for another method";40 if (body !== "" && tagValues(e, "payload")[0] !== bytesToHex(sha256(new TextEncoder().encode(body)))) return "auth-required: token payload hash does not match the body";41 return e;42}4344// verifyBlossom checks a kind 24242 event for the given action.45export function verifyBlossom(header: string, action: BlossomAction, now: number): Event | string {46 const m = /^Nostr\s+(\S+)$/i.exec(header.trim());47 if (!m) return "auth-required: missing Blossom Authorization header";48 let e: Event;49 try {50 e = JSON.parse(atob(m[1].replace(/-/g, "+").replace(/_/g, "/")));51 } catch {52 return "auth-required: malformed Blossom token";53 }54 const bad = validate(e);55 if (bad) return "auth-required: " + bad;56 if (e.kind !== 24242) return "auth-required: token must be kind 24242";57 if (tagValues(e, "t")[0] !== action) return `auth-required: token is not for ${action}`;58 const exp = Number(tagValues(e, "expiration")[0]);59 if (!Number.isFinite(exp) || exp <= now) return "auth-required: token expired";60 if (e.created_at > now + 300) return "auth-required: token is from the future";61 return e;62}6364// whoAsks reads an optional Authorization header on a read door. No header65// is a stranger, an empty list. A NIP-98 token must fit this URL and method;66// a Blossom token must be for `action`, and when it carries x tags one of67// them must name `sha`. A header that fits neither is the reason, so a68// caller who tried to sign learns what went wrong instead of being served69// as a stranger.70export function whoAsks(req: Request, body: string, action: BlossomAction | null, sha = ""): { pubkeys: string[] } | string {71 const header = req.headers.get("authorization") ?? "";72 if (header.trim() === "") return { pubkeys: [] };73 let peek: { kind?: unknown } | null = null;74 try {75 const m = /^Nostr\s+(\S+)$/i.exec(header.trim());76 peek = m ? JSON.parse(atob(m[1].replace(/-/g, "+").replace(/_/g, "/"))) : null;77 } catch {78 peek = null;79 }80 if (peek?.kind === 24242) {81 if (!action) return "auth-required: this door takes a NIP-98 signature";82 const e = verifyBlossom(header, action, now());83 if (typeof e === "string") return e;84 const named = tagValues(e, "x");85 if (sha && named.length && !named.includes(sha)) return "auth-required: token x tag does not name this blob";86 return { pubkeys: [e.pubkey] };87 }88 const e = verifyNIP98(header, req.url, req.method, body);89 if (typeof e === "string") return e;90 return { pubkeys: [e.pubkey] };91}9293// deny maps a gate's reason to a status: auth-required is 401, anything94// else the gate says is 403.95export const denyStatus = (reason: string) => (reason.startsWith("auth-required") ? 401 : 403);