- commit
- 5192613b0f3e190dbc32e0f7d213f1f9f07db3ca
- parent
- 9f66a15b225006faf13c68e7375403e4822d602a
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 2 | +- |
| M | src/ | 23 | ++++++++++++++------ |
| M | test/ | 34 | +++++++++++++++++++++++++----- |
@@ -43,7 +43,7 @@ Custom hostnames (`domains.ts`) are registered with Cloudflare for SaaS from the
4343 4444 The constructor runs schema setup inside `blockConcurrencyWhile`, then handles fetches. Websockets use the hibernation API. Per-connection state (NIP-42 challenge, authenticated pubkeys, open subscriptions, the client's address) is stored on the socket with `serializeAttachment`, so it survives hibernation. Negentropy sessions are in memory only and end with `closed:` when the object sleeps.4545 46-The write path is one gate plus the client policy. The gate refuses malformed, expired, banned and out-of-context events (an `h` tag for another group) and, for client writes, the unclaimed and expired-lease states, fuel and NIP-70. The client policy adds kind rules, retention windows, the write rule, proof of work and per-member caps. Host-side writes, such as pulls and the relay's own records, pass the gate only. Kind 24133, NIP-46 signer traffic, passes everything but bans and rate limits on the way in: it is ephemeral, never stored and encrypted end to end, so the relay can carry a remote signer's session for the console, including for someone about to claim it. On the way out it is a private kind like a gift wrap: delivered only to a socket authenticated as its sender or its `p` recipient. A subscription to 24133 alone may be opened under any read rule, so a signer can subscribe before its AUTH lands; it receives nothing until it does. The console's remote-signer client answers the challenge with the session's own key.46+The write path is one gate plus the client policy. The gate refuses malformed, expired, banned and out-of-context events (an `h` tag for another group) and, for client writes, the unclaimed and expired-lease states, fuel and NIP-70. The client policy adds kind rules, retention windows, the write rule, proof of work and per-member caps. Host-side writes, such as pulls and the relay's own records, pass the gate only. Kind 24133, NIP-46 signer traffic, passes everything but bans and rate limits on the way in: it is ephemeral, never stored and encrypted end to end, so the relay can carry a remote signer's session for the console, including for someone about to claim it. On the way out it is a private kind, delivered to a socket authenticated as its sender or its `p` recipient, or to a subscription that names one of those keys in `#p` or `authors` on a socket the read rule admits: the payload is encrypted end to end, and a remote signer such as Amethyst's listens under a transport key it never authenticates with, so the filter is the only proof it gives. A bare subscription to the kind sees nothing, and on a members-only relay a stranger sees nothing at all. A subscription to 24133 alone may be opened under any read rule. The console's remote-signer client answers the challenge with the session's own key.4747 4848 The read path is one gate too, `Settings.mayRead`, given the pubkeys a caller has proved: on the socket the NIP-42 set, over HTTP whatever `whoAsks` (`auth.ts`) found in the Authorization header, a NIP-98 signature or a Blossom token. REQ, COUNT, NEG-OPEN, the bridge, blob downloads and listings, file reports, the same-host file copy and the members-only views all ask it; the directory switch adds `mayList` for names and counts. Changing the rule runs `enforceReads`, which closes every open subscription the rule no longer admits.4949 @@ -1487,19 +1487,28 @@ export class Relay extends DurableObject<Env> {
14871487 return gone;14881488 }14891489 1490- private canSee(s: ConnState, e: Event): boolean {1490+ // canSee decides whether a private kind reaches a socket through the1491+ // filter that matched it. A socket that has AUTHed as the sender or a1492+ // recipient always sees it. NIP-46 traffic is also delivered to a filter1493+ // that names a party's key outright, in "#p" or "authors", on a socket the1494+ // read rule admits: the payload is encrypted end to end, and remote signers1495+ // such as Amethyst's listen under a transport key they never AUTH with, so1496+ // the filter is the only proof they give. A bare subscription to the kind1497+ // sees nothing, and a members-only relay shows a stranger nothing at all.1498+ private canSee(s: ConnState, e: Event, f: Filter): boolean {14911499 if (!isPrivate(e.kind)) return true;1492- if (s.authed.includes(e.pubkey)) return true;1493- return tagValues(e, "p").some((p) => s.authed.includes(p));1500+ const parties = [e.pubkey, ...tagValues(e, "p")];1501+ if (parties.some((p) => s.authed.includes(p))) return true;1502+ if (e.kind !== KIND_NOSTR_CONNECT || this.settings.mayRead(s.authed)) return false;1503+ return [...(f.authors ?? []), ...(f.tags.p ?? [])].some((k) => parties.includes(k));14941504 }14951505 14961506 broadcast(e: Event) {14971507 const raw = canonical(e);14981508 for (const ws of this.ctx.getWebSockets()) {14991509 const s = this.state(ws);1500- if (!this.canSee(s, e)) continue;15011510 for (const [id, filters] of Object.entries(s.subs)) {1502- if (filters.some((f) => match(f, e))) {1511+ if (filters.some((f) => match(f, e) && this.canSee(s, e, f))) {15031512 this.raw(ws, `["EVENT",${JSON.stringify(id)},${raw}]`);15041513 break;15051514 }@@ -1523,8 +1532,8 @@ export class Relay extends DurableObject<Env> {
15231532 // hint applies because private kinds were silently filtered out.15241533 allowFilters(s: ConnState, filters: Filter[]): { reason: string; authHint: boolean } {15251534 // 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).1535+ // policy, with or without AUTH; the traffic itself is a private kind,1536+ // delivered only to its parties (canSee).15281537 if (filters.length > 0 && filters.every((f) => f.kinds?.length === 1 && f.kinds[0] === KIND_NOSTR_CONNECT)) return { reason: "", authHint: false };15291538 const authed = s.authed.length > 0;15301539 const gate = this.settings.mayRead(s.authed);@@ -78,22 +78,31 @@ describe("NIP-46 transport", () => {
7878 const host = "phone.bind.ws";7979 const signerKey = generateSecretKey();8080 const client = generateSecretKey();81+ // A signer that names its key in the filter and never AUTHs, the way82+ // Amethyst's bunker listens under a transport key.8183 const listener = await WS.connect(host);8284 expect(await listener.open("nc", { kinds: [24133], "#p": [getPublicKey(signerKey)] })).toBe("");83- // A bystander with the same subscription, who has not proved the key.85+ // A bystander subscribed to the kind alone, naming no key.8486 const bystander = await WS.connect(host);85- expect(await bystander.open("nc", { kinds: [24133], "#p": [getPublicKey(signerKey)] })).toBe("");87+ expect(await bystander.open("nc", { kinds: [24133] })).toBe("");8688 8789 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);9290 const req = ev(client, 24133, "ciphertext", [["p", getPublicKey(signerKey)]]);9391 expect(await sender.ok(req)).toEqual({ ok: true, msg: "" });9492 const got = await listener.expect("EVENT");9593 expect(got[1]).toBe("nc");9694 expect(got[2].id).toBe(req.id);95+ // The reply, addressed to the client, reaches a socket that AUTHed as the96+ // client, and one that asks by the signer's key as author.97+ const clientSock = await WS.connect(host);98+ expect(await clientSock.open("nc", { kinds: [24133] })).toBe("");99+ await clientSock.auth(client, host);100+ const byAuthor = await WS.connect(host);101+ expect(await byAuthor.open("nc", { kinds: [24133], authors: [getPublicKey(signerKey)] })).toBe("");102+ const reply = ev(signerKey, 24133, "ciphertext", [["p", getPublicKey(client)]]);103+ expect(await listener.ok(reply)).toEqual({ ok: true, msg: "" });104+ expect((await clientSock.expect("EVENT"))[2].id).toBe(reply.id);105+ expect((await byAuthor.expect("EVENT"))[2].id).toBe(reply.id);97106 // The bystander saw neither; a later frame proves the queue is empty.98107 expect(await bystander.open("probe", { kinds: [24133] })).toBe("");99108 // Anything else is still refused while unclaimed, and the request left no trace.@@ -130,6 +139,19 @@ describe("NIP-46 transport", () => {
130139 expect(await c.open("c", { kinds: [24133, 1] })).toMatch(/^auth-required/);131140 expect(await c.open("d", { kinds: [24133] }, { kinds: [1] })).toMatch(/^auth-required/);132141 expect(await c.open("e", {})).toMatch(/^auth-required/);142+ // A member's app listens under a transport key it never AUTHs with, on143+ // the socket it AUTHed as the member: requests to that key arrive. The144+ // same filter on a stranger's socket gets nothing.145+ const transport = generateSecretKey();146+ await c.auth(owner, host);147+ expect(await c.open("nc", { kinds: [24133], "#p": [getPublicKey(transport)] })).toBe("");148+ const stranger = await WS.connect(host);149+ expect(await stranger.open("nc", { kinds: [24133], "#p": [getPublicKey(transport)] })).toBe("");150+ const app = generateSecretKey();151+ const req = ev(app, 24133, "ciphertext", [["p", getPublicKey(transport)]]);152+ expect(await stranger.ok(req)).toEqual({ ok: true, msg: "" });153+ expect((await c.expect("EVENT"))[2].id).toBe(req.id);154+ expect(await stranger.open("probe", { kinds: [24133] })).toBe("");133155 });134156 135157 it("serves the signer library with a long cache", async () => {