| … | … | @@ -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 | |
| 18 | import ProtomuxRPC from 'protomux-rpc' |
| 19 | import c from 'compact-encoding' |
| 20 | import b4a from 'b4a' |
| 21 | import { signAttested } from 'favela-identity/lib/attestation.js' |
| 22 | import { claimBytes, releaseBytes } from 'favela-identity/lib/bridge-claim.js' |
| 23 | |
| 24 | export const PROTOCOL = 'favela/bridge-register/1' |
| 25 | const HEX64 = /^[0-9a-f]{64}$/i |
| 26 | const 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 | */ |
| 40 | export 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 | |
| 72 | const 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 | */ |
| 81 | export 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. */ |
| 90 | export 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 | |
| 95 | export default { connectBridge, buildClaim, buildRelease, PROTOCOL } |