clone

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

relay: every door that shows a member, an event or a file asks the read rule

Dami committed 2341c31
commit
2341c31ced0f31119eeba4466840c2e271163a1c
parent
453e022c841649228706b37f68322aa3c639b667
author
Dami <dami.etoile@gmail.com>
12 files changed, +140 -42
Msrc/blossom.ts31++++++++++++++---
Msrc/card.ts10++++--
Msrc/event.ts5++-
Msrc/manage.ts17+++++++--
Msrc/nip05.ts9+++--
Msrc/nip96.ts11+++---
Msrc/pull.ts6+++-
Msrc/relay.ts55+++++++++++++++++++++++-------
Msrc/views.ts4+-
Mtest/fuel.test.ts12++++++-
Mtest/nip46.test.ts19++++++++++-
Mtest/relay.test.ts3+-
M src/blossom.ts +25 -6
@@ -10,12 +10,15 @@
1010 // HEAD /upload t=upload; X-SHA-256, X-Content-Type, X-Content-Length (BUD-06)1111 // PUT /mirror t=upload, x=<sha256>; body {url} -> descriptor (BUD-04)1212 // PUT /report body: kind 1984 with x tags (BUD-09)13-// GET /<sha256>[.ext] public14-// HEAD /<sha256>[.ext] public13+// GET /<sha256>[.ext] the read rule; t=get or NIP-98 when reads are not open14+// HEAD /<sha256>[.ext] same1515 // DELETE /<sha256> t=delete, x=<sha256>; uploader or owner16-// GET /list/<pubkey> public16+// GET /list/<pubkey> the read rule; t=list or NIP-98 when reads are not open17+//18+// Files follow the relay's read rule (Settings.mayRead), the same gate as19+// events: on a members-only relay a blob is a member's, not a public URL.1720 import { sha256 } from "@noble/hashes/sha2.js";18-import { verifyBlossom } from "./auth.ts";21+import { verifyBlossom, whoAsks, denyStatus } from "./auth.ts";1922 export { verifyBlossom } from "./auth.ts";2023 import { tagValues, validate, type Event } from "./event.ts";2124 import { bytesToHex } from "./negentropy.ts";
@@ -249,6 +252,10 @@ export async function blossom(relay: Relay, req: Request): Promise<Response> {
249252 if (bad) return reason(bad, 400);250253 if (e.kind !== KIND_REPORT) return reason("invalid: report must be kind 1984", 400);251254 if (Math.abs(now - e.created_at) > 3600) return reason("invalid: report is too old or from the future", 400);255+ // The reporter must be someone the read rule admits, before any hash is256+ // looked up: whether this relay holds a file is a member's to know.257+ const gate = relay.settings.mayRead([e.pubkey]);258+ if (gate) return reason(gate, denyStatus(gate));252259 const r = fileReport(relay, e);253260 if (typeof r === "string") return reason(r, r.startsWith("not found") ? 404 : r.startsWith("invalid") ? 400 : 403);254261 return json({ ok: true, filed: r });
@@ -257,11 +264,16 @@ export async function blossom(relay: Relay, req: Request): Promise<Response> {
257264 if (req.method === "GET" && url.pathname.startsWith("/list/")) {258265 const pk = url.pathname.slice(6);259266 if (!/^[0-9a-f]{64}$/.test(pk)) return reason("invalid: bad pubkey", 400);267+ const who = whoAsks(req, "", "list");268+ if (typeof who === "string") return reason(who, 401);269+ const gate = relay.settings.mayRead(who.pubkeys);270+ if (gate) return reason(gate, denyStatus(gate));260271 const rows = sql.exec<Blob>(`SELECT * FROM blobs WHERE uploader=? ORDER BY uploaded DESC LIMIT 500`, pk).toArray();261272 return json(rows.map((b) => descriptor(host, b)));262273 }263274 264- const m = SHA_RE.exec(url.pathname);275+ // /nip96/<sha> is the same door (nip96.ts hands it over unchanged).276+ const m = SHA_RE.exec(url.pathname.startsWith("/nip96/") ? url.pathname.slice("/nip96".length) : url.pathname);265277 if (!m) return reason("not found", 404);266278 const sha = m[1];267279 const blob = sql.exec<Blob>(`SELECT * FROM blobs WHERE sha256=?`, sha).toArray()[0];
@@ -277,12 +289,19 @@ export async function blossom(relay: Relay, req: Request): Promise<Response> {
277289 }278290 279291 if (req.method !== "GET" && req.method !== "HEAD") return reason("method not allowed", 405);292+ // The read rule, before the lookup: a stranger learns nothing, not even293+ // whether the hash is here. A gated answer is not for shared caches.294+ const open = relay.settings.policy.reads === "open";295+ const who = whoAsks(req, "", "get", sha);296+ if (typeof who === "string") return reason(who, 401);297+ const gate = relay.settings.mayRead(who.pubkeys);298+ if (gate) return reason(gate, denyStatus(gate));280299 if (!blob) return reason("not found", 404);281300 const headers: Record<string, string> = {282301 "content-type": blob.type,283302 "content-length": String(blob.size),284303 "accept-ranges": "bytes",285- "cache-control": "public, max-age=31536000, immutable",304+ "cache-control": open ? "public, max-age=31536000, immutable" : "private, no-store",286305 "access-control-allow-origin": "*",287306 "x-content-type-options": "nosniff",288307 "content-security-policy": "default-src 'none'",
M src/card.ts +7 -3
@@ -12,6 +12,7 @@ import { svg as qrSVG, encode as qrEncode } from "./qr.ts";
1212 import { KIND_GROUP_METADATA } from "./identity.ts";1313 import { escapeHTML } from "./ui.ts";1414 import type { Relay } from "./relay.ts";15+import { whoAsks } from "./auth.ts";1516 1617 export const KIND_APP_DATA = 30078;1718 export const CARD_D = "bind.ws/card";
@@ -38,7 +39,8 @@ export interface Card {
3839 image?: string;3940 }4041 41-export function cardData(relay: Relay, host: string): Card {42+// caller is the pubkeys the request proved, for the directory switch.43+export function cardData(relay: Relay, host: string, caller: string[] = []): Card {4244 const p = relay.settings.policy;4345 const s = relay.settings;4446 const url = relay.relayURL(host);
@@ -65,7 +67,7 @@ export function cardData(relay: Relay, host: string): Card {
6567 card.self = self;6668 card.naddr = naddrEncode({ kind: KIND_GROUP_METADATA, pubkey: self, identifier: relay.slug, relays: [url] });6769 }68- if (p.directoryPublic) card.members = s.members().length;70+ if (s.mayList(caller) === "") card.members = s.members().length;6971 return card;7072 }7173
@@ -80,7 +82,9 @@ export function card(relay: Relay, req: Request): Response {
8082 if (bytes.length > QR_MAX_BYTES) return json({ error: `invalid: at most ${QR_MAX_BYTES} bytes` }, 413);8183 return new Response(qrSVG(bytes, { margin: 2 }), { headers: headers("image/svg+xml") });8284 }83- const data = cardData(relay, url.host);85+ const who = whoAsks(req, "", null);86+ if (typeof who === "string") return json({ error: who }, 401);87+ const data = cardData(relay, url.host, who.pubkeys);8488 if (url.pathname === "/card.json") return json(data);8589 if (url.pathname === "/card.nostr") {8690 if (data.state !== "claimed") return json({ error: "restricted: this relay has no owner to sign for it" }, 404);
M src/event.ts +3 -2
M src/manage.ts +13 -4
@@ -8,7 +8,7 @@ import { descriptor, type Blob } from "./blossom.ts";
88 import { blockedWords, gateFields, isWriteRule, viewFields } from "./settings.ts";99 import { isReplaceable, isProtected, publicFields, dumpFields } from "./settings.ts";1010 import { checkPullURL } from "./pull.ts";11-import { viewsSummary } from "./views.ts";11+import { VIEWS, viewsSummary } from "./views.ts";1212 import { MAX_PINS } from "./groups.ts";1313 import { relaysFromList } from "./jobs.ts";1414 import { notify, notifySettings } from "./notify.ts";
@@ -108,7 +108,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
108108 case "stats": {109109 const d = new Date(now() * 1000);110110 const monthStart = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1) / 1000;111- return reply({ result: { ...relay.store.stats(), kinds: relay.store.kinds(monthStart), connections: relay.connections(), name: relay.slug, owner: p.owner, fuel: relay.fuelStatus() } });111+ return reply({ result: { ...relay.store.stats(), kinds: relay.store.kinds(monthStart), connections: relay.connections(), name: relay.slug, owner: p.owner, fuel: relay.fuelStatus(), credits: relay.fuel.recentCredits() } });112112 }113113 case "getpolicy":114114 return reply({ result: p });
@@ -130,9 +130,15 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
130130 if (typeof clean.maxLimit === "number") clean.maxLimit = Math.min(Math.max(clean.maxLimit as number, 1), 5000);131131 if (typeof clean.maxSubs === "number") clean.maxSubs = Math.min(Math.max(clean.maxSubs as number, 1), 200);132132 s.update(clean);133+ // A read rule that tightened ends the subscriptions it no longer admits.134+ relay.enforceReads();133135 await relay.publishMembership();134- // A view switched off is taken down now; one switched on is published now.135- if (patch.views && typeof patch.views === "object") for (const name of Object.keys(patch.views as object)) await relay.publishView(name);136+ // A view switched off is taken down now; one switched on is published137+ // now, and every view is republished when the rules changed, since138+ // the read rule and the directory decide each one's audience.139+ const names = patch.views && typeof patch.views === "object" ? Object.keys(patch.views as object) : [];140+ if (clean.reads !== undefined || clean.directoryPublic !== undefined) for (const v of VIEWS) if (!names.includes(v.name)) names.push(v.name);141+ for (const name of names) await relay.publishView(name);136142 return reply({ result: s.policy });137143 }138144 case "listviews":
@@ -417,6 +423,7 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
417423 }418424 const err = applyPreset(s, preset.name);419425 if (err) return reply({ error: err }, 400);426+ relay.enforceReads();420427 for (const j of await relay.jobs()) if (j.label === "replica") await relay.removeJob(j.id);421428 let job = null;422429 if (source) {
@@ -425,6 +432,8 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
425432 job = r;426433 }427434 await relay.publishMembership();435+ // The read rule and the directory moved, so every view's audience may have.436+ for (const v of VIEWS) await relay.publishView(v.name);428437 return reply({ result: { ...s.policy, job } });429438 }430439 case "forkrelay": {
M src/nip05.ts +6 -3
M src/nip96.ts +5 -6
M src/pull.ts +5 -1
M src/relay.ts +43 -12
@@ -23,7 +23,7 @@ import { KIND_GROUP_PINS } from "./identity.ts";
2323 import { DIGEST_DAYS, digestText } from "./notify.ts";2424 import { claimFromProfile, nip05Document } from "./nip05.ts";2525 import { checkInvite, claimInvite, inviteCreator, invitePage, termsPage } from "./invites.ts";26-import { verifyNIP98 } from "./manage.ts";26+import { verifyNIP98, whoAsks } from "./auth.ts";2727 import { FAVICON_SVG } from "./ui.ts";2828 import type { PullFilter, PullJob, PullResult } from "./pull.ts";2929 import { leaseDays, leaseNames, validName } from "./names.ts";
@@ -419,6 +419,7 @@ export class Relay extends DurableObject<Env> {
419419 if (!s.authed.includes(pubkey)) continue;420420 for (const id of Object.keys(s.subs)) this.send(ws, "CLOSED", id, reason);421421 s.subs = {};422+ this.syncs.delete(ws);422423 this.persist(ws, s);423424 if (close) {424425 try {
@@ -430,6 +431,26 @@ export class Relay extends DurableObject<Env> {
430431 }431432 }432433 434+ // enforceReads runs after the read rule changed: every open subscription435+ // is judged again and the ones the rule no longer admits are closed, so a436+ // relay that turned members-only stops feeding the sockets it let in437+ // while it was open. Sync sessions go with them.438+ enforceReads() {439+ for (const ws of this.ctx.getWebSockets()) {440+ const s = this.state(ws);441+ let changed = false;442+ for (const [id, filters] of Object.entries(s.subs)) {443+ const { reason } = this.allowFilters(s, filters);444+ if (!reason) continue;445+ this.send(ws, "CLOSED", id, reason);446+ delete s.subs[id];447+ changed = true;448+ }449+ if (this.settings.mayRead(s.authed)) this.syncs.delete(ws);450+ if (changed) this.persist(ws, s);451+ }452+ }453+433454 // virtualConn represents an HTTP caller as if it had authenticated over the socket.434455 virtualConn(host: string, pubkey: string, ip = "unknown"): ConnState {435456 void this.ownerSeen(pubkey);
@@ -521,8 +542,11 @@ export class Relay extends DurableObject<Env> {
521542 this.sql.exec(`DELETE FROM blobs WHERE sha256=?`, sha);522543 }523544 524- // listBlobs is for another relay on this host pulling our files.525- listBlobs(): Blob[] {545+ // listBlobs is for another relay on this host pulling our files. The546+ // caller says which pubkeys it has proved; the read rule decides.547+ listBlobs(pubkeys: string[]): Blob[] | string {548+ const gate = this.settings.mayRead(pubkeys);549+ if (gate) return gate;526550 return this.sql.exec<Blob>(`SELECT * FROM blobs ORDER BY uploaded`).toArray();527551 }528552
@@ -946,7 +970,7 @@ export class Relay extends DurableObject<Env> {
946970 // events or files. The page, NIP-11 and management stay reachable, so947971 // an owner who blocked their own address can undo it.948972 const upgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";949- const door = upgrade || (req.method === "POST" && (url.pathname === "/events" || url.pathname === "/query" || url.pathname === "/count" || url.pathname === "/api/invites/claim" || url.pathname === "/fuel/invoice")) || url.pathname === "/upload" || url.pathname === "/mirror" || url.pathname.startsWith("/list/") || isBlobPath(url.pathname);973+ const door = upgrade || (req.method === "POST" && (url.pathname === "/events" || url.pathname === "/query" || url.pathname === "/count" || url.pathname === "/api/invites/claim" || url.pathname === "/fuel/invoice")) || url.pathname === "/upload" || url.pathname === "/mirror" || url.pathname === "/report" || url.pathname.startsWith("/list/") || isBlobPath(url.pathname);950974 if (door && this.settings.isIPBlocked(clientIP(req))) {951975 const msg = "blocked: this address is blocked from this relay";952976 return new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "content-type": "application/json", "x-reason": msg, "access-control-allow-origin": "*" } });
@@ -960,11 +984,16 @@ export class Relay extends DurableObject<Env> {
960984 if (req.method === "POST" && req.headers.get("content-type")?.includes("application/nostr+json+rpc")) return manage(this, req);961985 if (req.method === "POST" && (url.pathname === "/events" || url.pathname === "/query" || url.pathname === "/count")) return bridge(this, req);962986 if (url.pathname === "/.well-known/nostr.json") {963- return Response.json(nip05Document(this.settings, url.searchParams.get("name"), this.relayURL(url.host)), { headers: { "access-control-allow-origin": "*" } });987+ const who = whoAsks(req, "", null);988+ if (typeof who === "string") return Response.json({ error: who }, { status: 401, headers: { "access-control-allow-origin": "*" } });989+ return Response.json(nip05Document(this.settings, url.searchParams.get("name"), this.relayURL(url.host), who.pubkeys), { headers: { "access-control-allow-origin": "*" } });964990 }965991 if (url.pathname === "/people" && req.method === "GET") {966992 const p = this.settings.policy;967- const people = p.owner && p.directoryPublic ? this.settings.members().map((m) => ({ pubkey: m.pubkey, role: m.role, name: m.name })) : [];993+ const who = whoAsks(req, "", null);994+ if (typeof who === "string") return Response.json({ error: who }, { status: 401, headers: { "access-control-allow-origin": "*" } });995+ const listed = p.owner !== "" && this.settings.mayList(who.pubkeys) === "";996+ const people = listed ? this.settings.members().map((m) => ({ pubkey: m.pubkey, role: m.role, name: m.name })) : [];968997 return Response.json({ public: p.directoryPublic, self: this.identity.pubkey, host: url.host, people }, { headers: { "access-control-allow-origin": "*" } });969998 }970999 if (url.pathname === "/terms" && req.method === "GET") {
@@ -987,7 +1016,9 @@ export class Relay extends DurableObject<Env> {
9871016 if (url.pathname === "/upload" || url.pathname === "/mirror" || url.pathname === "/report" || url.pathname.startsWith("/list/") || isBlobPath(url.pathname)) return blossom(this, req);9881017 if (url.pathname === "/.well-known/nostr/nip96.json" || url.pathname === "/nip96" || url.pathname.startsWith("/nip96/")) return nip96(this, req);9891018 if (url.pathname === "/fuel" && req.method === "GET") {990- return Response.json({ ...this.fuelStatus(), credits: this.fuel.recentCredits() }, { headers: { "access-control-allow-origin": "*" } });1019+ // Meters and prices for anyone who might top up; who paid is the1020+ // owner's, in the stats method.1021+ return Response.json(this.fuelStatus(), { headers: { "access-control-allow-origin": "*" } });9911022 }9921023 if (url.pathname === "/fuel/invoice" && req.method === "POST") return this.fuelInvoice(req);9931024 if (url.pathname === "/card.json" || url.pathname === "/card.nostr" || url.pathname === "/card.svg" || url.pathname === "/qr.svg") return card(this, req);
@@ -1491,13 +1522,13 @@ export class Relay extends DurableObject<Env> {
14911522 // allowFilters returns a CLOSED reason, or "" plus whether an EOSE "auth"14921523 // hint applies because private kinds were silently filtered out.14931524 allowFilters(s: ConnState, filters: Filter[]): { reason: string; authHint: boolean } {1494- // A subscription to NIP-46 traffic alone is served under any read1495- // policy: see accept for why the relay carries it.1525+ // A subscription to NIP-46 traffic alone may be opened under any read1526+ // policy, so a signer can subscribe before its AUTH lands; the traffic1527+ // itself is a private kind, delivered only to its parties (canSee).14961528 if (filters.length > 0 && filters.every((f) => f.kinds?.length === 1 && f.kinds[0] === KIND_NOSTR_CONNECT)) return { reason: "", authHint: false };14971529 const authed = s.authed.length > 0;1498- const reads = this.settings.policy.reads;1499- if (reads !== "open" && !authed) return { reason: "auth-required: this relay requires authentication", authHint: false };1500- if (reads === "members" && !s.authed.some((pk) => this.settings.isAllowed(pk))) return { reason: "restricted: this relay only serves its members", authHint: false };1530+ const gate = this.settings.mayRead(s.authed);1531+ if (gate) return { reason: gate, authHint: false };15011532 if (authed) return { reason: "", authHint: false };15021533 let authHint = false;15031534 for (const f of filters) {
M src/views.ts +2 -2
M test/fuel.test.ts +11 -1
M test/nip46.test.ts +18 -1
@@ -29,12 +29,20 @@ class WS {
2929 else this.queue.push(m);3030 });3131 }32+ challenge = "";3233 static async connect(host: string) {3334 const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });3435 const c = new WS(resp.webSocket!);35- await c.expect("AUTH");36+ c.challenge = (await c.expect("AUTH"))[1];3637 return c;3738 }39+ // auth proves a key over the socket: 24133 is a private kind, delivered40+ // only to a socket that has proved it is the sender or the recipient.41+ async auth(sk: Uint8Array, host: string) {42+ this.send("AUTH", ev(sk, 22242, "", [["relay", "ws://" + host], ["challenge", this.challenge]]));43+ const m = await this.expect("OK");44+ expect(m[2], m[3]).toBe(true);45+ }3846 send(...m: unknown[]) {3947 this.ws.send(JSON.stringify(m));4048 }
@@ -72,13 +80,22 @@ describe("NIP-46 transport", () => {
7280 const client = generateSecretKey();7381 const listener = await WS.connect(host);7482 expect(await listener.open("nc", { kinds: [24133], "#p": [getPublicKey(signerKey)] })).toBe("");83+ // A bystander with the same subscription, who has not proved the key.84+ const bystander = await WS.connect(host);85+ expect(await bystander.open("nc", { kinds: [24133], "#p": [getPublicKey(signerKey)] })).toBe("");7586 7687 const sender = await WS.connect(host);88+ const early = ev(client, 24133, "before auth", [["p", getPublicKey(signerKey)]]);89+ expect(await sender.ok(early)).toEqual({ ok: true, msg: "" });90+ // Nothing reaches a socket that has not authenticated as a party.91+ await listener.auth(signerKey, host);7792 const req = ev(client, 24133, "ciphertext", [["p", getPublicKey(signerKey)]]);7893 expect(await sender.ok(req)).toEqual({ ok: true, msg: "" });7994 const got = await listener.expect("EVENT");8095 expect(got[1]).toBe("nc");8196 expect(got[2].id).toBe(req.id);97+ // The bystander saw neither; a later frame proves the queue is empty.98+ expect(await bystander.open("probe", { kinds: [24133] })).toBe("");8299 // Anything else is still refused while unclaimed, and the request left no trace.83100 expect((await sender.ok(ev(client, 1, "hello"))).msg).toMatch(/unclaimed/);84101 const later = await WS.connect(host);
M test/relay.test.ts +2 -1