- commit
- b86c9274ad9dd5fc2320c7aa923dc4ca30eba6df
- parent
- d68e14293de2d90631919f2f1273822f4140018c
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 95 | ++++++++++++++++++++++++++++++ |
| M | src/ | 22 | +------ |
| M | src/ | 34 | +---------- |
@@ -0,0 +1,95 @@
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.7+import { sha256 } from "@noble/hashes/sha2.js";8+import { now, tagValues, validate, type Event } from "./event.ts";9+import { bytesToHex } from "./negentropy.ts";10+11+export type BlossomAction = "get" | "list" | "upload" | "delete";12+13+// 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.17+export 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+}43+44+// verifyBlossom checks a kind 24242 event for the given action.45+export 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+}63+64+// 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.70+export 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+}92+93+// deny maps a gate's reason to a status: auth-required is 401, anything94+// else the gate says is 403.95+export const denyStatus = (reason: string) => (reason.startsWith("auth-required") ? 401 : 403);@@ -15,6 +15,8 @@
1515 // DELETE /<sha256> t=delete, x=<sha256>; uploader or owner1616 // GET /list/<pubkey> public1717 import { sha256 } from "@noble/hashes/sha2.js";18+import { verifyBlossom } from "./auth.ts";19+export { verifyBlossom } from "./auth.ts";1820 import { tagValues, validate, type Event } from "./event.ts";1921 import { bytesToHex } from "./negentropy.ts";2022 import { localName } from "./pull.ts";@@ -38,26 +40,6 @@ const TYPE_BY_EXT: Record<string, string> = Object.fromEntries(Object.entries(EX
3840 const SHA_RE = /^\/([0-9a-f]{64})(?:\.[a-z0-9]{1,8})?$/;3941 const HEX64 = /^[0-9a-f]{64}$/;4042 41-// verifyBlossom checks a kind 24242 event for the given action.42-export function verifyBlossom(header: string, action: "upload" | "delete", now: number): Event | string {43- const m = /^Nostr\s+(\S+)$/i.exec(header.trim());44- if (!m) return "auth-required: missing Blossom Authorization header";45- let e: Event;46- try {47- e = JSON.parse(atob(m[1].replace(/-/g, "+").replace(/_/g, "/")));48- } catch {49- return "auth-required: malformed Blossom token";50- }51- const bad = validate(e);52- if (bad) return "auth-required: " + bad;53- if (e.kind !== 24242) return "auth-required: token must be kind 24242";54- if (tagValues(e, "t")[0] !== action) return `auth-required: token is not for ${action}`;55- const exp = Number(tagValues(e, "expiration")[0]);56- if (!Number.isFinite(exp) || exp <= now) return "auth-required: token expired";57- if (e.created_at > now + 300) return "auth-required: token is from the future";58- return e;59-}60-6143 export function isBlobPath(path: string): boolean {6244 return SHA_RE.test(path);6345 }@@ -1,9 +1,7 @@
11 // NIP-86 relay management over HTTP, authenticated with NIP-98. The owner is22 // whoever claimed the relay; "claim" is the one method anyone may call, and33 // only while the relay is unclaimed.4-import { sha256 } from "@noble/hashes/sha2.js";54 import { now, tagValues, validate, type Event } from "./event.ts";6-import { bytesToHex } from "./negentropy.ts";75 import type { Relay } from "./relay.ts";86 import { inviteCreator, listInvites, memberInviteGate, mintInvite, revokeInvite } from "./invites.ts";97 import { descriptor, type Blob } from "./blossom.ts";@@ -19,36 +17,8 @@ import { can, METHOD_ACTIONS } from "./roles.ts";
1917 import { PRESETS, applyPreset, findPreset } from "./presets.ts";2018 import { addDomain, checkDomain, listDomains, removeDomain } from "./domains.ts";2119 22-// verifyNIP98 checks an "Authorization: Nostr <base64 event>" header against23-// the request: kind 27235, fresh, u tag naming this URL, method tag, and a24-// payload tag equal to the SHA-256 of the exact body. Returns the event or25-// the reason it was rejected.26-export function verifyNIP98(header: string, url: string, method: string, body: string): Event | string {27- const m = /^Nostr\s+(\S+)$/i.exec(header.trim());28- if (!m) return "auth-required: missing NIP-98 Authorization header";29- let e: Event;30- try {31- e = JSON.parse(atob(m[1]));32- } catch {33- return "auth-required: malformed NIP-98 token";34- }35- const bad = validate(e);36- if (bad) return "auth-required: " + bad;37- if (e.kind !== 27235) return "auth-required: token must be kind 27235";38- if (Math.abs(now() - e.created_at) > 60) return "auth-required: token expired";39- const want = new URL(url);40- const got = (() => {41- try {42- return new URL(tagValues(e, "u")[0] ?? "");43- } catch {44- return null;45- }46- })();47- if (!got || got.host.toLowerCase() !== want.host.toLowerCase() || got.pathname !== want.pathname) return "auth-required: token was signed for another URL";48- if ((tagValues(e, "method")[0] ?? "").toUpperCase() !== method.toUpperCase()) return "auth-required: token was signed for another method";49- if (body !== "" && tagValues(e, "payload")[0] !== bytesToHex(sha256(new TextEncoder().encode(body)))) return "auth-required: token payload hash does not match the body";50- return e;51-}20+import { verifyNIP98 } from "./auth.ts";21+export { verifyNIP98 };5222 5323 const METHODS = [5424 "supportedmethods", "claim", "stats", "getpolicy", "setpolicy",