OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-svc commits / 53c3e79

client: verify WHICH peer answered a connectSwarm, and stop leaking swarms (0.1.9)
`serverKey` is a rendezvous address, not an identity. The topic is hash(serverKey + salt) and SwarmTransport.listen() builds its Hyperswarm with NO keyPair, so a server's Noise key is random per process and never equals the serverKey clients use to find it. Any peer joined to that topic as a server can answer, and there is nothing to check conn.remotePublicKey against. In a pear-git-v3 fleet a mirror joins its ORIGIN's topic (the topic ships in the deployment bundle), making it a server on the exact topic used to reach the origin. Measured 2026-08-08: connectSwarm({serverKey: <tabertron>}) issued ON stortron was answered by STORTRON ITSELF 6 times out of 6. It fetched its own release index believing it was the origin's, then failed every artifact with "no holder"; resync-from-origin on the same path hung twice at 300 s, binding its own trackers. A wrong ANSWER, not a transport error — so it read as a data problem and went unnoticed for as long as the path has existed. - opts.verify(client) => boolean gates the peer at the APPLICATION layer, where identity lives. Rejected peers are destroyed and the wait continues until timeout, so a caller reaches the intended daemon or fails loudly. A callback rather than a built-in `status` probe keeps hyper-svc generic and compatible with servers of any version. - Every failure path now destroys the swarm. It used to be left running, and a Hyperswarm holds the event loop open — each failed connect leaked one. That is what hung pear-git-v3's basic.test.js: 123/123 passed, then the process never exited. - Connections get an 'error' listener on arrival (matching SwarmTransport._onConnection). This path destroys connections by design, and a NoiseSecretStream with no listener throws on ECONNRESET — the first rejected peer would otherwise kill the process. The listener stays attached for the swarm's life: detaching it left post-accept arrivals unguarded, which crashed teardown. Verified: 5/5 new tests (two servers on one topic reproduce the prod shape and the gate picks the right one; all-reject is a loud timeout; a throwing verify is a rejection; failed connect leaves no swarm), existing suite 23/23. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
53c3e79f450cae92b466009262dee64232604788
by plankatron <fleeky@gmail.com> · 2026-08-08T19:57:42.000Z

Changed files (3) +253 -19

lib/client.js +101 -18
……@@ -86,15 +86,42 @@ export class Client {
8686 }
8787
8888 /**
89 * Connect to a remote daemon via Hyperswarm. Joins the topic derived
90 * from the server's public key, waits for the first connection, then
91 * attaches a Protomux RPC channel.
89 * Connect to a remote daemon via Hyperswarm. Joins the topic derived from
90 * `serverKey`, waits for a connection, attaches a Protomux RPC channel, and
91 * (when `opts.verify` is given) confirms the peer that answered is the one
92 * that was asked for.
93 *
94 * ⚠ `serverKey` IS NOT AN IDENTITY — IT IS ONLY A RENDEZVOUS ADDRESS.
95 *
96 * The topic is `hash(serverKey + salt)`, and SwarmTransport.listen() creates
97 * its Hyperswarm with NO keyPair, so a server's Noise key is random per
98 * process and never equals the `serverKey` clients use to find it. Anything
99 * joined to that topic as a server can therefore answer, and there is no
100 * cryptographic binding to check `conn.remotePublicKey` against.
101 *
102 * That is not hypothetical. In a pear-git-v3 fleet, a mirror joins its
103 * ORIGIN's topic (the topic travels in the deployment bundle), so it is a
104 * server on the exact topic clients use to reach the origin. Measured
105 * 2026-08-08: `connectSwarm({serverKey: <tabertron>})` issued ON stortron was
106 * answered by STORTRON ITSELF 6 times out of 6. It asked itself for the
107 * origin's release index, got its own, and every artifact fetch then failed
108 * with "no holder" — a wrong ANSWER, not a transport error, so nothing looked
109 * broken. resync-from-origin on the same path hung twice for 300 s.
110 *
111 * `opts.verify` closes that hole at the application layer, where identity
112 * actually lives. It receives a connected Client and returns truthy to accept.
113 * Rejected peers are destroyed and the wait continues until `timeout`, so a
114 * caller reaches the intended daemon or fails loudly, never silently talks to
115 * the wrong one. It is layered as a callback rather than a built-in `status`
116 * check because hyper-svc is generic — which handler proves identity is the
117 * consumer's business, and this stays compatible with servers of any version.
92118 *
93119 * @param {object} opts
94 * @param {string|Buffer} opts.serverKey - server's Noise public key (hex or Buffer)
120 * @param {string|Buffer} opts.serverKey - rendezvous key; derives the topic
95121 * @param {string} [opts.topicSalt='pear-git-v3'] - salt for topic derivation
96122 * @param {object} [opts.swarm] - Hyperswarm constructor options (e.g. testnet bootstrap)
97 * @param {number} [opts.timeout=30000] - connection timeout ms
123 * @param {number} [opts.timeout=30000] - total ms to find an ACCEPTED peer
124 * @param {(client: Client) => Promise<boolean>} [opts.verify] - identity gate
98125 * @returns {Promise<Client>}
99126 */
100127 static async connectSwarm (opts = {}) {
……@@ -105,25 +132,81 @@ export class Client {
105132 const salt = opts.topicSalt || 'pear-git-v3'
106133 const topic = crypto.hash(b4a.concat([serverKey, b4a.from(salt)]))
107134 const timeout = opts.timeout || 30_000
135 const verify = typeof opts.verify === 'function' ? opts.verify : null
108136
109137 const swarm = new Hyperswarm(opts.swarm || {})
110138 const discovery = swarm.join(topic, { server: false, client: true })
111139
112 const conn = await new Promise((resolve, reject) => {
113 const timer = setTimeout(() => {
114 reject(new Error('swarm-rpc: connection timeout'))
115 }, timeout)
116 swarm.on('connection', (c) => {
117 clearTimeout(timer)
118 resolve(c)
119 })
120 discovery.flushed().catch(reject)
121 })
140 let rejected = 0
141 let client = null
142 try {
143 client = await new Promise((resolve, reject) => {
144 let settled = false
145 // NOTE: onConnection stays attached for the swarm's whole life — it is
146 // deliberately NOT removed here. Connections can still arrive after a
147 // peer is accepted, and a NoiseSecretStream with no 'error' listener
148 // throws on reset, so detaching left later arrivals unguarded and
149 // crashed the process at teardown with ECONNRESET. The listener dies
150 // with the swarm when the returned Client is closed.
151 const finish = (fn, arg) => {
152 if (settled) return
153 settled = true
154 clearTimeout(timer)
155 fn(arg)
156 }
157 const timer = setTimeout(() => {
158 finish(reject, new Error('swarm-rpc: connection timeout' +
159 (rejected ? ' (' + rejected + ' peer(s) answered but failed verification — ' +
160 'the requested serverKey is a topic, not an identity, so other peers on ' +
161 'that topic can answer)' : '')))
162 }, timeout)
163
164 const onConnection = (c) => {
165 // Routine, and load-bearing: a NoiseSecretStream with no 'error'
166 // listener throws on ECONNRESET, and this path DESTROYS connections
167 // by design (every peer that fails verification, plus any that arrive
168 // after one is accepted). Without this the first rejected peer takes
169 // the whole process down. SwarmTransport._onConnection takes the same
170 // posture server-side.
171 c.on('error', () => {})
172 if (settled) { try { c.destroy() } catch {} ; return }
173 const candidate = new Client({
174 kind: 'swarm',
175 swarm,
176 conn: c,
177 rpc: attachRpcClient(Protomux.from(c))
178 })
179 if (!verify) return finish(resolve, candidate)
180
181 // Verified off the critical path: keep listening while this runs, so a
182 // slow or lying peer cannot stall a good one behind it.
183 Promise.resolve()
184 .then(() => verify(candidate))
185 .then((ok) => {
186 if (ok) return finish(resolve, candidate)
187 rejected++
188 try { c.destroy() } catch {}
189 })
190 .catch(() => {
191 rejected++
192 try { c.destroy() } catch {}
193 })
194 }
122195
123 const mux = Protomux.from(conn)
124 const rpc = attachRpcClient(mux)
196 swarm.on('connection', onConnection)
197 discovery.flushed().catch((err) => finish(reject, err))
198 })
199 } catch (err) {
200 // Destroy the swarm on EVERY failure path. It used to be left running: a
201 // Hyperswarm holds the event loop open, so each failed connect leaked one
202 // and the process never exited. That is what hung pear-git-v3's
203 // basic.test.js when a mirror's origin was unreachable — 123/123 tests
204 // passed, then the run never terminated.
205 try { await swarm.destroy() } catch {}
206 throw err
207 }
125208
126 return new Client({ kind: 'swarm', swarm, conn, rpc })
209 return client
127210 }
128211
129212 constructor (opts) {
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-svc",
3 "version": "0.1.8",
3 "version": "0.1.9",
44 "description": "Reusable per-app daemon library \u2014 UDS + HTTP for local RPC, Hyperswarm for peer mirroring",
55 "type": "module",
66 "main": "index.js",
test/connect-swarm-verify.test.js +151 -0
……@@ -0,0 +1,151 @@
1// connectSwarm's identity gate + the failed-connect swarm leak.
2//
3// Context (measured 2026-08-08, not hypothetical): `serverKey` only derives the
4// topic — SwarmTransport.listen() builds its Hyperswarm with NO keyPair, so a
5// server's Noise key is random per process and never equals the serverKey.
6// In a pear-git-v3 fleet a mirror joins its ORIGIN's topic, making it a server
7// on the very topic clients use to reach the origin. On stortron,
8// connectSwarm({serverKey: <tabertron>}) was answered by STORTRON ITSELF 6/6:
9// it asked itself for the origin's release index, got its own, and every
10// artifact fetch failed "no holder". A wrong ANSWER, not a transport error.
11
12import test from 'brittle'
13import b4a from 'b4a'
14import crypto from 'hypercore-crypto'
15import createTestnet from '@hyperswarm/testnet'
16import Corestore from 'corestore'
17import { mkdtempSync } from '#node:fs'
18import { join } from '#node:path'
19import { tmpdir } from '#node:os'
20
21import { Service } from '../lib/service.js'
22import { Client } from '../lib/client.js'
23
24const tmpStore = () => new Corestore(mkdtempSync(join(tmpdir(), 'hsvc-verify-')))
25
26// Two services sharing ONE topic — the prod shape (origin + mirror).
27async function twoOnOneTopic (t, testnet, topic) {
28 const made = []
29 for (const name of ['origin', 'impostor']) {
30 const store = tmpStore()
31 const svc = new Service({
32 name,
33 corestore: store,
34 swarm: { topic, swarm: { bootstrap: testnet.bootstrap } }
35 })
36 svc.handle('status', async () => ({ identity: name }))
37 svc.handle('who', async () => name)
38 await svc.listen()
39 t.teardown(async () => {
40 try { await svc.close() } catch {}
41 try { await store.close() } catch {}
42 })
43 made.push(svc)
44 }
45 return made
46}
47
48test('without verify, ANY peer on the topic can answer (the bug)', async (t) => {
49 const testnet = await createTestnet(2, t.teardown)
50 const serverKey = b4a.alloc(32, 'unverified-topic')
51 const topic = crypto.hash(b4a.concat([serverKey, b4a.from('pear-git-v3')]))
52 await twoOnOneTopic(t, testnet, topic)
53
54 const client = await Client.connectSwarm({
55 serverKey, topicSalt: 'pear-git-v3', swarm: { bootstrap: testnet.bootstrap }
56 })
57 t.teardown(async () => { try { await client.close() } catch {} })
58
59 const who = await client.call('who')
60 // Deliberately NOT asserting which one — that is the point. Whoever gets
61 // there first answers, and the caller cannot tell. Documented, not desired.
62 t.ok(who === 'origin' || who === 'impostor', 'some peer answered: ' + who)
63})
64
65test('verify rejects the wrong peer and keeps waiting for the right one', async (t) => {
66 const testnet = await createTestnet(2, t.teardown)
67 const serverKey = b4a.alloc(32, 'verified-topic')
68 const topic = crypto.hash(b4a.concat([serverKey, b4a.from('pear-git-v3')]))
69 await twoOnOneTopic(t, testnet, topic)
70
71 const seen = []
72 const client = await Client.connectSwarm({
73 serverKey,
74 topicSalt: 'pear-git-v3',
75 timeout: 20000,
76 swarm: { bootstrap: testnet.bootstrap },
77 verify: async (c) => {
78 const st = await c.call('status', {})
79 seen.push(st.identity)
80 return st.identity === 'origin'
81 }
82 })
83 t.teardown(async () => { try { await client.close() } catch {} })
84
85 t.is(await client.call('who'), 'origin', 'reached the daemon that was ASKED for')
86 t.ok(seen.includes('origin'), 'verify ran against the accepted peer')
87})
88
89test('all peers failing verification is a loud timeout, not a wrong answer', async (t) => {
90 const testnet = await createTestnet(2, t.teardown)
91 const serverKey = b4a.alloc(32, 'nobody-matches')
92 const topic = crypto.hash(b4a.concat([serverKey, b4a.from('pear-git-v3')]))
93 await twoOnOneTopic(t, testnet, topic)
94
95 await t.exception(
96 () => Client.connectSwarm({
97 serverKey,
98 topicSalt: 'pear-git-v3',
99 timeout: 3000,
100 swarm: { bootstrap: testnet.bootstrap },
101 verify: async () => false
102 }),
103 /timeout/,
104 'fails rather than silently returning the wrong daemon'
105 )
106})
107
108test('a verify that throws is treated as rejection, not a crash', async (t) => {
109 const testnet = await createTestnet(2, t.teardown)
110 const serverKey = b4a.alloc(32, 'verify-throws')
111 const topic = crypto.hash(b4a.concat([serverKey, b4a.from('pear-git-v3')]))
112 await twoOnOneTopic(t, testnet, topic)
113
114 await t.exception(
115 () => Client.connectSwarm({
116 serverKey,
117 topicSalt: 'pear-git-v3',
118 timeout: 3000,
119 swarm: { bootstrap: testnet.bootstrap },
120 verify: async () => { throw new Error('handler missing on that peer') }
121 }),
122 /timeout/,
123 'a throwing verify cannot escape as an unhandled rejection'
124 )
125})
126
127test('a failed connect does not leak the Hyperswarm', async (t) => {
128 // The leak that hung pear-git-v3's basic.test.js: 123/123 passed, then the
129 // process never exited because each failed connect left a swarm holding the
130 // event loop open.
131 const testnet = await createTestnet(2, t.teardown)
132 const serverKey = b4a.alloc(32, 'nobody-home') // nothing serves this topic
133
134 const before = process._getActiveHandles ? process._getActiveHandles().length : null
135 await t.exception(
136 () => Client.connectSwarm({
137 serverKey, topicSalt: 'pear-git-v3', timeout: 1500,
138 swarm: { bootstrap: testnet.bootstrap }
139 }),
140 /timeout/,
141 'times out with nobody serving'
142 )
143 // Destruction is awaited inside connectSwarm's catch, so by here the swarm's
144 // sockets are already gone rather than merely scheduled for teardown.
145 if (before !== null) {
146 const after = process._getActiveHandles().length
147 t.ok(after <= before + 2, 'no swarm left running (handles ' + before + ' → ' + after + ')')
148 } else {
149 t.pass('handle introspection unavailable in this runtime')
150 }
151})