clone

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

relay: NIP-46 traffic reaches whoever names the key under any read rule, so Amber's probe passes

Dami committed 3d19ba1
commit
3d19ba14c6aa8d163cc7490cbc2a24a269cf70a4
parent
5192613b0f3e190dbc32e0f7d213f1f9f07db3ca
author
Dami <dami.etoile@gmail.com>
4 files changed, +26 -22
Mdocs/10-architecture.md2+-
Msrc/relay.ts13+++++++------
Mtest/exposure.test.ts6++++--
Mtest/nip46.test.ts27++++++++++++++-------------
M docs/10-architecture.md +1 -1
@@ -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, 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.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`, under any read rule and without AUTH: the payload is encrypted end to end, and signers and their clients hold no key the relay knows. Amber probes a relay this way before it will list it, and Amethyst's bunker listens under a transport key it never authenticates with. A bare subscription to the kind sees nothing, so a members-only relay reveals signer traffic only to someone who already holds the key it is addressed to. 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
M src/relay.ts +7 -6
@@ -1490,16 +1490,17 @@ export class Relay extends DurableObject<Env> {
14901490 // canSee decides whether a private kind reaches a socket through the14911491 // filter that matched it. A socket that has AUTHed as the sender or a14921492 // 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.1493+ // that names a party's key outright, in "#p" or "authors", under any read1494+ // rule and without AUTH: the payload is encrypted end to end, and signers1495+ // and their clients hold no key this relay knows. Amber probes a relay1496+ // this way before it will list it, and Amethyst's bunker listens under a1497+ // transport key it never AUTHs with. A bare subscription to the kind1498+ // still sees nothing.14981499 private canSee(s: ConnState, e: Event, f: Filter): boolean {14991500 if (!isPrivate(e.kind)) return true;15001501 const parties = [e.pubkey, ...tagValues(e, "p")];15011502 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+ if (e.kind !== KIND_NOSTR_CONNECT) return false;15031504 return [...(f.authors ?? []), ...(f.tags.p ?? [])].some((k) => parties.includes(k));15041505 }15051506
M test/exposure.test.ts +4 -2
M test/nip46.test.ts +14 -13
@@ -139,19 +139,20 @@ describe("NIP-46 transport", () => {
139139 expect(await c.open("c", { kinds: [24133, 1] })).toMatch(/^auth-required/);140140 expect(await c.open("d", { kinds: [24133] }, { kinds: [1] })).toMatch(/^auth-required/);141141 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("");142+ // Amber's probe before it lists a relay: a throwaway key, no AUTH, a143+ // subscription naming the key, and its own event echoed back.144+ const probe = generateSecretKey();145+ expect(await c.open("nc", { kinds: [24133], "#p": [getPublicKey(probe)] })).toBe("");146+ const self = ev(probe, 24133, "Test bunker event", [["p", getPublicKey(probe)]]);147+ expect(await c.ok(self)).toEqual({ ok: true, msg: "" });148+ expect((await c.expect("EVENT"))[2].id).toBe(self.id);149+ // A signer's reply reaches the client that asked for it by key, unauthed.150+ const client = generateSecretKey();151+ const d = await WS.connect(host);152+ expect(await d.open("nc", { kinds: [24133], "#p": [getPublicKey(client)] })).toBe("");153+ const reply = ev(owner, 24133, "ciphertext", [["p", getPublicKey(client)]]);154+ expect(await c.ok(reply)).toEqual({ ok: true, msg: "" });155+ expect((await d.expect("EVENT"))[2].id).toBe(reply.id);155156 });156157 157158 it("serves the signer library with a long cache", async () => {