OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / aa76e5c

a peer can ask a bridge to carry it — bridge-policy / claim / release
This daemon learns nothing about the network. It asks a bridge for a NAME and hands back the answer; what the name means is the bridge's business and the caller's. boundary.test.js still passes, which is the check that matters: the same three verbs serve any bridge, so it is one ceremony rather than one per network. Over the swarm, not HTTP. This package has no outbound HTTP at all and should not gain one — a client would also inherit the bare-https defect where an idle socket is never reaped (24.3 GB, two OOMs). Both ends are already swarm members. ⛔ Addressed by KEY, not hostname. A topic derived from a name is squattable, and while a squatter cannot steal a claim — root and feed key are public, the proof is bound to a nonce — it CAN answer "registered!" and leave a node recording a name that does not exist. Connecting to a key means the answer came from the holder of that key or from nobody. ⚠ The result is deliberately NOT recorded here. `set-reach` stays a separate act, because holding a name and publishing under it are different decisions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aa76e5c3c177919d1d7e03d2e431fb8e843f39db
by plankatron <fleeky@gmail.com> · 2026-09-11T16:20:09.000Z

Changed files (3) +144 -2

lib/bridge.js +95 -0
……@@ -0,0 +1,95 @@
1// bridge.js — asking a bridge to carry this identity onto a network it does not speak.
2//
3// ⛔ THIS DAEMON LEARNS NOTHING ABOUT THE NETWORK. `test/boundary.test.js` forbids naming a
4// projection here, from hyper-social #61 §10 — "the moment the daemon imports the projection the
5// dependency has been inverted". So this asks a bridge for a NAME and records the answer; what that
6// name means, and what the bridge does with the feed, is the bridge's business. The same verbs serve
7// any bridge, which is the point: one ceremony, not one per network.
8//
9// ⛔ WHY THE SWARM AND NOT HTTP. This package has no outbound HTTP at all and should not gain one:
10// adding a client would also inherit the bare-https defect where an idle socket is never reaped
11// (24.3 GB, two OOMs). Both ends are already swarm members. A bridge is a service peers connect to.
12//
13// ⛔ THE BYTES ARE NOT DEFINED HERE. They come from favela-identity, which the bridge also uses, for
14// a reason worth stating: if the two sides disagreed by one byte the verifier would return null and
15// the bridge would answer "proof does not attest that identity" — indistinguishable from a forged
16// proof. There is no error that says "we disagree about the format".
17
18import ProtomuxRPC from 'protomux-rpc'
19import c from 'compact-encoding'
20import b4a from 'b4a'
21import { signAttested } from 'favela-identity/lib/attestation.js'
22import { claimBytes, releaseBytes } from 'favela-identity/lib/bridge-claim.js'
23
24export const PROTOCOL = 'favela/bridge-register/1'
25const HEX64 = /^[0-9a-f]{64}$/i
26const DEFAULT_TIMEOUT = 20_000
27
28/**
29 * Open an RPC channel to a bridge, by its key.
30 *
31 * ⛔ THE KEY, NOT A HOSTNAME. A topic derived from a name is squattable, and a squatter cannot steal
32 * a claim — root and feed key are public and the proof is bound to a nonce — but it CAN answer
33 * "registered!" and leave a node recording a name that does not exist. Connecting to a key means the
34 * answer came from the holder of that key or from nobody.
35 *
36 * ⚠ `{ client: true }` on our side and the bridge joins `{ server: true, client: true }`: two
37 * firewalled hosts only punch when both send outbound, which hyper-proto-peer measured — symmetric
38 * connects in ~2s, asymmetric never open at all.
39 */
40export async function connectBridge (swarm, bridgeKey, { timeoutMs = DEFAULT_TIMEOUT } = {}) {
41 if (!swarm) throw new Error('this node has no swarm, so it cannot reach a bridge')
42 const key = String(bridgeKey || '').replace(/^bridge:/, '').toLowerCase()
43 if (!HEX64.test(key)) throw new Error('a bridge key is 64 hex characters — check what you pasted')
44 const topic = b4a.from(key, 'hex')
45
46 const existing = [...swarm.connections].find((conn) =>
47 conn.remotePublicKey && b4a.equals(conn.remotePublicKey, topic))
48 if (existing) return { rpc: rpcOver(existing), close: () => {} }
49
50 const discovery = swarm.join(topic, { client: true, server: false })
51 const conn = await new Promise((resolve, reject) => {
52 const timer = setTimeout(() => {
53 swarm.off('connection', onconn)
54 reject(new Error('the bridge did not answer within ' + Math.round(timeoutMs / 1000) + 's — it may be offline'))
55 }, timeoutMs)
56 const onconn = (conn) => {
57 if (!conn.remotePublicKey || !b4a.equals(conn.remotePublicKey, topic)) return
58 clearTimeout(timer)
59 swarm.off('connection', onconn)
60 resolve(conn)
61 }
62 swarm.on('connection', onconn)
63 discovery.flushed().catch(() => {})
64 })
65
66 // ⚠ The discovery is left joined for the life of the node rather than torn down per request: a
67 // claim is usually followed by the bridge holding this feed, and dropping the topic between the
68 // two would close the connection the bridge is about to use.
69 return { rpc: rpcOver(conn), close: () => {} }
70}
71
72const rpcOver = (conn) => new ProtomuxRPC(conn, { protocol: PROTOCOL, valueEncoding: c.json })
73
74/**
75 * The claim a peer sends. Pure — no network, no clock — so what gets signed is testable on its own.
76 *
77 * ⛔ `cred` is this device's credential, the same one that signs room writes. The bridge verifies the
78 * chain from the identity ROOT down to this device, so a device that was never attested cannot
79 * register a name, which is the correct answer: it cannot write to the feed either.
80 */
81export function buildClaim ({ nonce, username, feedKey, root, cred, invite = null }) {
82 if (!root) throw new Error('this node cannot sign yet — link this device to its identity first')
83 const proof = signAttested(claimBytes({ nonce, username, feedKey }), cred)
84 const req = { username, root, feedKey, nonce, proof }
85 if (invite) req.invite = invite
86 return req
87}
88
89/** The release a peer sends. Separate bytes, so a claim proof can never be repurposed to burn a name. */
90export function buildRelease ({ nonce, username, root, cred }) {
91 if (!root) throw new Error('this node cannot sign yet — link this device to its identity first')
92 return { username, root, nonce, proof: signAttested(releaseBytes({ nonce, username }), cred) }
93}
94
95export default { connectBridge, buildClaim, buildRelease, PROTOCOL }
lib/rpc.js +47 -1
……@@ -100,7 +100,11 @@ const MUTATIONS = new Set(['post', 'follow', 'unfollow', 'like', 'unlike', 'repl
100100 // under this identity somewhere that does not forget, so it requires an ATTESTED device — the
101101 // same gate as posting. A browser cannot do it without one, which is the whole point of the
102102 // browser being a controller rather than a device (see browsers.js).
103 'set-reach'])
103 'set-reach',
104 // ⛔ Asking a bridge to carry this identity puts a name for it on somebody's domain, which is the
105 // same class of act as turning a network on — so it needs an ATTESTED device, not merely a
106 // signed-in browser.
107 'bridge-claim', 'bridge-release'])
104108
105109/**
106110 * The room, or a refusal that says which of the two possible things is wrong.
……@@ -375,6 +379,48 @@ export async function dispatch (node, cmd, a) {
375379 }
376380 case 'set-profile': { const r = await peer.setProfile(a); await peer.update(); return r }
377381 // Which networks this identity publishes to. ⚠ A preference, not a bridge — see peer.reach().
382 // ── bridges ────────────────────────────────────────────────────────────────────────────────
383 //
384 // ⛔ THIS DAEMON LEARNS NOTHING ABOUT THE NETWORK. It asks a bridge for a NAME and hands back the
385 // answer; what the name means is the bridge's business and the caller's. The same three verbs
386 // serve any bridge, which is the point — one ceremony rather than one per network.
387 //
388 // ⚠ The RESULT IS NOT RECORDED HERE. `set-reach` is a separate act because designating where this
389 // identity publishes is a separate decision from holding a name — a person may hold one and not
390 // publish under it.
391 case 'bridge-policy': {
392 const { connectBridge } = await import('./bridge.js')
393 const { rpc } = await connectBridge(node.swarm, a.bridge)
394 return rpc.request('policy', {})
395 }
396 case 'bridge-claim': {
397 const { connectBridge, buildClaim } = await import('./bridge.js')
398 const { rpc } = await connectBridge(node.swarm, a.bridge)
399 // ⛔ A FRESH CHALLENGE PER ATTEMPT. It is single-use at the bridge, so reusing one across a
400 // retry fails as "unknown or expired challenge" — which reads like a hostile caller.
401 const { nonce } = await rpc.request('challenge', {})
402 const req = buildClaim({
403 nonce,
404 username: a.name,
405 feedKey: node.key.toString('hex'),
406 root: node.rootHex(),
407 cred: node.cred,
408 invite: a.invite || null
409 })
410 const r = await rpc.request('claim', req)
411 // The bridge answers in HTTP's vocabulary on purpose — it is already the contract a peer reads.
412 if (r.status !== 200 && r.status !== 202) throw new Error((r.body && r.body.error) || ('bridge refused: ' + r.status))
413 return { ...r.body, pending: r.status === 202 ? (r.body && r.body.pending) || true : false }
414 }
415 case 'bridge-release': {
416 const { connectBridge, buildRelease } = await import('./bridge.js')
417 const { rpc } = await connectBridge(node.swarm, a.bridge)
418 const { nonce } = await rpc.request('challenge', {})
419 const r = await rpc.request('release', buildRelease({ nonce, username: a.name, root: node.rootHex(), cred: node.cred }))
420 if (r.status !== 200) throw new Error((r.body && r.body.error) || ('bridge refused: ' + r.status))
421 return r.body
422 }
423
378424 case 'reach': return peer.reach()
379425 case 'set-reach': { const r = await peer.setReach(a); await peer.update(); return r }
380426 // Bytes cross the socket as base64 — it is a local UNIX socket carrying a small image, and a
package.json +2 -1
……@@ -52,7 +52,8 @@
5252 "keet-identity-key": "3.2.0",
5353 "protomux": "^3.11.0",
5454 "sodium-hmac": "^2.0.1",
55 "hyperident": "file:../repos/hyperident"
55 "hyperident": "file:../repos/hyperident",
56 "protomux-rpc": "^1.10.0"
5657 },
5758 "devDependencies": {
5859 "brittle": "^4.0.2"