- commit
- f6c2b5de956159a8972516d43fdd792d69ae258b
- parent
- 9d94d882b45fc96f34c4a1bec904fef250ffc695
- author
- Dami <dami.etoile@gmail.com>
| A | test/ | 329 | ++++++++++++++++++++++++++++++ |
@@ -0,0 +1,329 @@
1+// The read rule, door by door. A members-only relay with the directory off2+// holds a named member, her note and her file. A stranger and a signed-in3+// non-member then knock on every path the relay answers, over HTTP and the4+// socket, and nothing that names her, her note or her file may come back.5+// Then she asks, and it all does. This is the test that keeps the next6+// door honest: add a path here when you add one to the relay.7+import { SELF } from "cloudflare:test";8+import { describe, it, expect } from "vitest";9+import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";10+import { getToken } from "nostr-tools/nip98";11+import { npubEncode } from "nostr-tools/nip19";12+import { sha256 } from "@noble/hashes/sha2.js";13+import { bytesToHex } from "../src/negentropy.ts";14+import { VIEWS } from "../src/views.ts";15+16+const now = () => Math.floor(Date.now() / 1000);17+const ev = (sk: Uint8Array, kind: number, content: string, tags: string[][] = []) => finalizeEvent({ kind, content, tags, created_at: now() }, sk);18+const pk = (sk: Uint8Array) => getPublicKey(sk);19+20+async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {21+ const url = `http://${host}/`;22+ const payload = { method, params };23+ const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);24+ const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });25+ return { status: resp.status, ...(await resp.json<any>()) };26+}27+28+// nip98 signs a GET or a body-less request for the exact URL.29+const nip98 = (sk: Uint8Array, url: string, method = "GET") => getToken(url, method, (e) => finalizeEvent(e, sk), true);30+// nip98Body signs a request with a body, for the bridge.31+const nip98Body = (sk: Uint8Array, url: string, method: string, body: unknown) => getToken(url, method, (e) => finalizeEvent(e, sk), true, body as object);32+// blossom mints a kind 24242 token for an action, optionally naming a hash.33+const blossom = (sk: Uint8Array, action: string, sha?: string) =>34+ "Nostr " + btoa(JSON.stringify(ev(sk, 24242, action, [["t", action], ...(sha ? [["x", sha]] : []), ["expiration", String(now() + 300)]])));35+36+class WS {37+ private queue: any[][] = [];38+ private waiters: ((m: any[]) => void)[] = [];39+ challenge = "";40+ frames: string[] = []; // every frame received, for the leak check41+ constructor(public ws: WebSocket) {42+ ws.accept();43+ ws.addEventListener("message", (e) => {44+ this.frames.push(e.data as string);45+ const m = JSON.parse(e.data as string);46+ const w = this.waiters.shift();47+ if (w) w(m);48+ else this.queue.push(m);49+ });50+ }51+ static async connect(host: string) {52+ const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });53+ const c = new WS(resp.webSocket!);54+ c.challenge = (await c.expect("AUTH"))[1];55+ return c;56+ }57+ send(...m: unknown[]) {58+ this.ws.send(JSON.stringify(m));59+ }60+ recv(): Promise<any[]> {61+ const m = this.queue.shift();62+ if (m) return Promise.resolve(m);63+ return new Promise((res) => this.waiters.push(res));64+ }65+ async expect(type: string) {66+ const m = await this.recv();67+ expect(m[0], JSON.stringify(m)).toBe(type);68+ return m;69+ }70+ async auth(sk: Uint8Array, host: string) {71+ this.send("AUTH", ev(sk, 22242, "", [["relay", "ws://" + host], ["challenge", this.challenge]]));72+ const m = await this.expect("OK");73+ expect(m[2], m[3]).toBe(true);74+ }75+ async ok(e: Event) {76+ this.send("EVENT", e);77+ const m = await this.expect("OK");78+ return { ok: m[2] as boolean, msg: m[3] as string };79+ }80+ // open subscribes and returns the events plus "" on EOSE, or the CLOSED reason.81+ async open(id: string, ...filters: unknown[]) {82+ this.send("REQ", id, ...filters);83+ const events: Event[] = [];84+ for (;;) {85+ const m = await this.recv();86+ if (m[0] === "EVENT" && m[1] === id) events.push(m[2]);87+ else if (m[0] === "EOSE" && m[1] === id) return { events, closed: "" };88+ else if (m[0] === "CLOSED" && m[1] === id) return { events, closed: m[2] as string };89+ else throw new Error(JSON.stringify(m));90+ }91+ }92+ async count(id: string, filter: unknown) {93+ this.send("COUNT", id, filter);94+ const m = await this.recv();95+ return m[0] === "COUNT" ? { count: m[2].count as number, closed: "" } : { count: -1, closed: m[2] as string };96+ }97+ async sync(id: string, filter: unknown) {98+ this.send("NEG-OPEN", id, filter, "61");99+ const m = await this.recv();100+ return m[0] === "NEG-ERR" ? (m[2] as string) : "";101+ }102+}103+104+interface Fixture {105+ host: string;106+ owner: Uint8Array;107+ eve: Uint8Array;108+ stranger: Uint8Array;109+ sha: string;110+ noteId: string;111+ secrets: string[];112+}113+114+async function seed(host: string): Promise<Fixture> {115+ const owner = generateSecretKey();116+ const eve = generateSecretKey();117+ const stranger = generateSecretKey();118+ await rpc(host, owner, "claim");119+ expect((await rpc(host, owner, "setmember", pk(eve), { name: "evelynq7" })).status).toBe(200);120+ const body = new TextEncoder().encode("eve's quarterly numbers");121+ const sha = bytesToHex(sha256(body));122+ const up = await SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: blossom(eve, "upload", sha), "content-type": "text/plain" }, body });123+ expect([200, 201]).toContain(up.status);124+ const c = await WS.connect(host);125+ const note = ev(eve, 1, "the numbers are in");126+ expect((await c.ok(note)).ok).toBe(true);127+ c.ws.close();128+ await rpc(host, owner, "setpolicy", { reads: "members", writes: "allowlist", directoryPublic: false });129+ return { host, owner, eve, stranger, sha, noteId: note.id, secrets: [pk(eve), npubEncode(pk(eve)), note.id, sha, "evelynq7"] };130+}131+132+// Every HTTP path a relay answers that could carry something of a member's.133+function doors(f: Fixture): { path: string; method?: string; gated: boolean; blossom?: string }[] {134+ return [135+ { path: "/", gated: false },136+ { path: "/people", gated: false },137+ { path: "/.well-known/nostr.json", gated: false },138+ { path: "/.well-known/nostr.json?name=nobodyq7", gated: false },139+ { path: `/list/${pk(f.eve)}`, gated: true, blossom: "list" },140+ { path: `/${f.sha}`, gated: true, blossom: "get" },141+ { path: `/${f.sha}`, method: "HEAD", gated: true, blossom: "get" },142+ { path: `/nip96/${f.sha}`, gated: true, blossom: "get" },143+ { path: "/nip96?page=0", gated: true },144+ ...VIEWS.map((v) => ({ path: `/view/${v.name}`, gated: true })),145+ { path: "/fuel", gated: false },146+ { path: "/card.json", gated: false },147+ { path: "/card.nostr", gated: false },148+ { path: "/card.svg", gated: false },149+ { path: `/e/${f.noteId}`, gated: false },150+ { path: `/feed.xml?author=${pk(f.eve)}`, gated: false },151+ { path: "/terms", gated: false },152+ { path: "/api/join-policy", gated: false },153+ { path: "/.well-known/nostr/nip96.json", gated: false },154+ ];155+}156+157+// leaks returns which secrets appear in a text.158+const leaks = (text: string, secrets: string[]) => secrets.filter((s) => text.includes(s));159+160+describe("the read rule at every door", () => {161+ it("shows a stranger nothing of a member, her note or her file", async () => {162+ const f = await seed("exposure-stranger.bind.ws");163+ for (const d of doors(f)) {164+ const resp = await SELF.fetch(`http://${f.host}${d.path}`, { method: d.method ?? "GET" });165+ const text = await resp.text();166+ expect(leaks(text, f.secrets), `${d.method ?? "GET"} ${d.path} -> ${resp.status}`).toEqual([]);167+ if (d.gated) expect(resp.status, d.path).toBe(401);168+ }169+ // The information document names the owner and the relay, nothing else.170+ const info = await SELF.fetch(`http://${f.host}/`, { headers: { accept: "application/nostr+json" } });171+ const doc = await info.json<any>();172+ expect(doc.pubkey).toBe(pk(f.owner));173+ expect(leaks(JSON.stringify(doc), f.secrets)).toEqual([]);174+ for (const v of doc.views) expect(v.audience, v.name).toBe("members");175+ // Pages and the feed are gone, not just empty.176+ expect((await SELF.fetch(`http://${f.host}/e/${f.noteId}`)).status).toBe(404);177+ expect((await SELF.fetch(`http://${f.host}/feed.xml`)).status).toBe(404);178+ // A report about her file from a stranger is refused before the hash is looked up.179+ const report = ev(f.stranger, 1984, "", [["x", f.sha, "spam"]]);180+ const filed = await SELF.fetch(`http://${f.host}/report`, { method: "PUT", body: JSON.stringify(report) });181+ expect(filed.status).toBe(403);182+ const other = ev(f.stranger, 1984, "", [["x", "ab".repeat(32), "spam"]]);183+ expect((await SELF.fetch(`http://${f.host}/report`, { method: "PUT", body: JSON.stringify(other) })).status).toBe(403);184+ // The bridge without a signature.185+ for (const path of ["/query", "/count"]) {186+ const resp = await SELF.fetch(`http://${f.host}${path}`, { method: "POST", body: JSON.stringify([{ authors: [pk(f.eve)] }]) });187+ expect(resp.status).toBe(401);188+ expect(leaks(await resp.text(), f.secrets)).toEqual([]);189+ }190+191+ // The socket: REQ, COUNT and NEG-OPEN are refused; signer traffic to her is not delivered.192+ const c = await WS.connect(f.host);193+ expect((await c.open("r", { authors: [pk(f.eve)] })).closed).toMatch(/^auth-required/);194+ expect((await c.count("n", { authors: [pk(f.eve)] })).closed).toMatch(/^auth-required/);195+ expect(await c.sync("s", { authors: [pk(f.eve)] })).toMatch(/^auth-required/);196+ expect((await c.open("nc", { kinds: [24133], "#p": [pk(f.eve)] })).closed).toBe("");197+ const sender = await WS.connect(f.host);198+ expect((await sender.ok(ev(generateSecretKey(), 24133, "ciphertext", [["p", pk(f.eve)]]))).ok).toBe(true);199+ // A later round trip proves no EVENT frame was queued in between.200+ expect((await c.open("probe", { kinds: [24133] })).events).toEqual([]);201+ expect(leaks(c.frames.join("\n"), f.secrets.filter((s) => s !== pk(f.eve)))).toEqual([]);202+ });203+204+ it("shows a signed-in non-member the same nothing, with 403 where the stranger got 401", async () => {205+ const f = await seed("exposure-outsider.bind.ws");206+ for (const d of doors(f)) {207+ const url = `http://${f.host}${d.path}`;208+ const method = d.method ?? "GET";209+ const headers = { authorization: await nip98(f.stranger, url, method) };210+ const resp = await SELF.fetch(url, { method, headers });211+ expect(leaks(await resp.text(), f.secrets), `${method} ${d.path} -> ${resp.status}`).toEqual([]);212+ if (d.gated) expect(resp.status, d.path).toBe(403);213+ if (d.blossom) {214+ const withToken = await SELF.fetch(url, { method, headers: { authorization: blossom(f.stranger, d.blossom, d.blossom === "get" ? f.sha : undefined) } });215+ expect(withToken.status, `${d.path} with a Blossom ${d.blossom} token`).toBe(403);216+ expect(leaks(await withToken.text(), f.secrets)).toEqual([]);217+ }218+ }219+ for (const path of ["/query", "/count"]) {220+ const url = `http://${f.host}${path}`;221+ const body = [{ authors: [pk(f.eve)] }];222+ const resp = await SELF.fetch(url, { method: "POST", headers: { authorization: await nip98Body(f.stranger, url, "POST", body) }, body: JSON.stringify(body) });223+ expect(resp.status, path).toBe(403);224+ expect(leaks(await resp.text(), f.secrets)).toEqual([]);225+ }226+ const c = await WS.connect(f.host);227+ await c.auth(f.stranger, f.host);228+ expect((await c.open("r", { authors: [pk(f.eve)] })).closed).toMatch(/^restricted/);229+ expect((await c.count("n", { authors: [pk(f.eve)] })).closed).toMatch(/^restricted/);230+ expect(await c.sync("s", { authors: [pk(f.eve)] })).toMatch(/^restricted/);231+ expect(leaks(c.frames.join("\n"), f.secrets.filter((s) => s !== pk(f.eve)))).toEqual([]);232+ });233+234+ it("answers the member herself at every one of them", async () => {235+ const f = await seed("exposure-member.bind.ws");236+ const get = async (path: string, auth: string) => {237+ const resp = await SELF.fetch(`http://${f.host}${path}`, { headers: { authorization: auth } });238+ return { status: resp.status, text: await resp.text() };239+ };240+ const url = (path: string) => `http://${f.host}${path}`;241+ // Her file, with a Blossom get token and with NIP-98.242+ let r = await get(`/${f.sha}`, blossom(f.eve, "get", f.sha));243+ expect(r.status).toBe(200);244+ expect(r.text).toBe("eve's quarterly numbers");245+ r = await get(`/${f.sha}`, await nip98(f.eve, url(`/${f.sha}`)));246+ expect(r.status).toBe(200);247+ // A get token naming another blob does not open this one.248+ expect((await get(`/${f.sha}`, blossom(f.eve, "get", "ab".repeat(32)))).status).toBe(401);249+ r = await get(`/list/${pk(f.eve)}`, blossom(f.eve, "list"));250+ expect(r.status).toBe(200);251+ expect(r.text).toContain(f.sha);252+ r = await get(`/.well-known/nostr.json`, await nip98(f.eve, url(`/.well-known/nostr.json`)));253+ expect(JSON.parse(r.text).names.evelynq7).toBe(pk(f.eve));254+ // Her name resolves for anyone: she published that address herself.255+ expect(JSON.parse((await get(`/.well-known/nostr.json?name=evelynq7`, "")).text).names.evelynq7).toBe(pk(f.eve));256+ for (const v of VIEWS) {257+ r = await get(`/view/${v.name}`, await nip98(f.eve, url(`/view/${v.name}`)));258+ expect([200, 404], `${v.name} -> ${r.status} ${r.text}`).toContain(r.status);259+ }260+ r = await get(`/people`, await nip98(f.eve, url(`/people`)));261+ expect(JSON.parse(r.text).people.map((m: any) => m.pubkey)).toContain(pk(f.eve));262+ const c = await WS.connect(f.host);263+ await c.auth(f.eve, f.host);264+ expect((await c.open("r", { authors: [pk(f.eve)] })).events.map((e) => e.id)).toEqual([f.noteId]);265+ });266+267+ it("closes the subscriptions a tightened read rule no longer admits", async () => {268+ const host = "exposure-tighten.bind.ws";269+ const owner = generateSecretKey();270+ await rpc(host, owner, "claim");271+ const bystander = await WS.connect(host);272+ expect((await bystander.open("feed", { kinds: [1] })).closed).toBe("");273+ const signer = await WS.connect(host);274+ expect((await signer.open("nc", { kinds: [24133] })).closed).toBe("");275+ await rpc(host, owner, "setpolicy", { reads: "members" });276+ const closed = await bystander.expect("CLOSED");277+ expect(closed[1]).toBe("feed");278+ expect(closed[2]).toMatch(/^auth-required/);279+ // The signer's 24133-only subscription stays open, and a fresh probe shows nothing else is queued for it.280+ expect((await signer.open("probe", { kinds: [24133] })).closed).toBe("");281+ // A stored note published after the change never reaches the bystander.282+ const member = generateSecretKey();283+ await rpc(host, owner, "allowpubkey", pk(member));284+ const m = await WS.connect(host);285+ expect((await m.ok(ev(member, 1, "after"))).ok).toBe(true);286+ expect((await bystander.open("again", { kinds: [1] })).closed).toMatch(/^auth-required/);287+ expect(bystander.frames.filter((x) => x.includes('"EVENT"'))).toEqual([]);288+ });289+290+ it("files follow the other two read rules the same way: anyone, then any signature", async () => {291+ const host = "exposure-rules.bind.ws";292+ const owner = generateSecretKey();293+ const stranger = generateSecretKey();294+ await rpc(host, owner, "claim");295+ const body = new TextEncoder().encode("a public file");296+ const sha = bytesToHex(sha256(body));297+ await SELF.fetch(`http://${host}/upload`, { method: "PUT", headers: { authorization: blossom(owner, "upload", sha), "content-type": "text/plain" }, body });298+ // Anyone: a public link by hash, cacheable.299+ let resp = await SELF.fetch(`http://${host}/${sha}`);300+ expect(resp.status).toBe(200);301+ expect(resp.headers.get("cache-control")).toContain("public");302+ expect((await SELF.fetch(`http://${host}/list/${pk(owner)}`)).status).toBe(200);303+ // Signed in: any valid signature, member or not; no signature is 401; not for shared caches.304+ await rpc(host, owner, "setpolicy", { reads: "auth" });305+ expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(401);306+ resp = await SELF.fetch(`http://${host}/${sha}`, { headers: { authorization: blossom(stranger, "get", sha) } });307+ expect(resp.status).toBe(200);308+ expect(resp.headers.get("cache-control")).toContain("no-store");309+ expect((await SELF.fetch(`http://${host}/list/${pk(owner)}`, { headers: { authorization: blossom(stranger, "list") } })).status).toBe(200);310+ // A token for the wrong action, or a malformed one, is refused with the reason rather than served as a stranger.311+ resp = await SELF.fetch(`http://${host}/${sha}`, { headers: { authorization: blossom(stranger, "upload", sha) } });312+ expect(resp.status).toBe(401);313+ expect(resp.headers.get("x-reason")).toMatch(/not for get/);314+ expect((await SELF.fetch(`http://${host}/${sha}`, { headers: { authorization: "Nostr nonsense" } })).status).toBe(401);315+ });316+317+ it("copies files between names on this host only from a relay that lets anyone read", async () => {318+ const host = "exposure-copy.bind.ws";319+ const owner = generateSecretKey();320+ await rpc(host, owner, "claim");321+ const { env } = await import("cloudflare:test");322+ const stub = env.RELAY.getByName("exposure-copy");323+ expect(Array.isArray(await stub.listBlobs([]))).toBe(true);324+ await rpc(host, owner, "setpolicy", { reads: "members" });325+ expect(await stub.listBlobs([])).toMatch(/^auth-required/);326+ expect(await stub.listBlobs([pk(generateSecretKey())])).toMatch(/^restricted/);327+ expect(Array.isArray(await stub.listBlobs([pk(owner)]))).toBe(true);328+ });329+});