- commit
- 3d19ba14c6aa8d163cc7490cbc2a24a269cf70a4
- parent
- 5192613b0f3e190dbc32e0f7d213f1f9f07db3ca
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 2 | +- |
| M | src/ | 13 | +++++++------ |
| M | test/ | 6 | ++++-- |
| M | test/ | 27 | ++++++++++++++------------- |
@@ -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 @@ -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 @@ -188,12 +188,14 @@ describe("the read rule at every door", () => {
188188 expect(leaks(await resp.text(), f.secrets)).toEqual([]);189189 }190190 191- // The socket: REQ, COUNT and NEG-OPEN are refused; signer traffic to her is not delivered.191+ // The socket: REQ, COUNT and NEG-OPEN are refused. Signer traffic is the192+ // one exception, and only to a filter that already names her key: the193+ // payload is ciphertext, and NIP-46 clients and signers never AUTH.192194 const c = await WS.connect(f.host);193195 expect((await c.open("r", { authors: [pk(f.eve)] })).closed).toMatch(/^auth-required/);194196 expect((await c.count("n", { authors: [pk(f.eve)] })).closed).toMatch(/^auth-required/);195197 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("");198+ expect((await c.open("nc", { kinds: [24133] })).closed).toBe("");197199 const sender = await WS.connect(f.host);198200 expect((await sender.ok(ev(generateSecretKey(), 24133, "ciphertext", [["p", pk(f.eve)]]))).ok).toBe(true);199201 // A later round trip proves no EVENT frame was queued in between.@@ -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 () => {