OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / lib / bridge.js

256 lines · javascript ref:
// bridge.js — asking a bridge to carry this identity onto a network it does not speak.
//
// ⛔ THIS DAEMON LEARNS NOTHING ABOUT THE NETWORK. `test/boundary.test.js` forbids naming a
// projection here, from hyper-social #61 §10 — "the moment the daemon imports the projection the
// dependency has been inverted". So this asks a bridge for a NAME and records the answer; what that
// name means, and what the bridge does with the feed, is the bridge's business. The same verbs serve
// any bridge, which is the point: one ceremony, not one per network.
//
// ⛔ WHY THE SWARM AND NOT HTTP. This package has no outbound HTTP at all and should not gain one:
// adding 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. A bridge is a service peers connect to.
//
// ⛔ THE BYTES ARE NOT DEFINED HERE. They come from favela-identity, which the bridge also uses, for
// a reason worth stating: if the two sides disagreed by one byte the verifier would return null and
// the bridge would answer "proof does not attest that identity" — indistinguishable from a forged
// proof. There is no error that says "we disagree about the format".

import ProtomuxRPC from 'protomux-rpc'
import c from 'compact-encoding'
import b4a from 'b4a'
import { signAttested } from 'favela-identity/lib/attestation.js'
import { claimBytes, releaseBytes, frontBytes, collectBytes } from 'favela-identity/lib/bridge-claim.js'

export const PROTOCOL = 'favela/bridge-register/1'
const HEX64 = /^[0-9a-f]{64}$/i
const DEFAULT_TIMEOUT = 20_000

/**
 * Open an RPC channel to a bridge, by its key.
 *
 * ⛔ THE KEY, NOT A HOSTNAME. A topic derived from a name is squattable, and a squatter cannot steal
 * a claim — root and feed key are public and the proof is bound to a nonce — but 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.
 *
 * ⚠ `{ client: true }` on our side and the bridge joins `{ server: true, client: true }`: two
 * firewalled hosts only punch when both send outbound, which hyper-proto-peer measured — symmetric
 * connects in ~2s, asymmetric never open at all.
 */
export async function connectBridge (swarm, bridgeKey, { timeoutMs = DEFAULT_TIMEOUT } = {}) {
  if (!swarm) throw new Error('this node has no swarm, so it cannot reach a bridge')
  const key = String(bridgeKey || '').replace(/^bridge:/, '').toLowerCase()
  if (!HEX64.test(key)) throw new Error('a bridge key is 64 hex characters — check what you pasted')
  const topic = b4a.from(key, 'hex')

  const existing = [...swarm.connections].find((conn) =>
    conn.remotePublicKey && b4a.equals(conn.remotePublicKey, topic))
  if (existing) return { rpc: rpcOver(existing), close: () => {} }

  const discovery = swarm.join(topic, { client: true, server: false })
  const conn = await new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      swarm.off('connection', onconn)
      reject(new Error('the bridge did not answer within ' + Math.round(timeoutMs / 1000) + 's — it may be offline'))
    }, timeoutMs)
    const onconn = (conn) => {
      if (!conn.remotePublicKey || !b4a.equals(conn.remotePublicKey, topic)) return
      clearTimeout(timer)
      swarm.off('connection', onconn)
      resolve(conn)
    }
    swarm.on('connection', onconn)
    discovery.flushed().catch(() => {})
  })

  // ⚠ The discovery is left joined for the life of the node rather than torn down per request: a
  // claim is usually followed by the bridge holding this feed, and dropping the topic between the
  // two would close the connection the bridge is about to use.
  return { rpc: rpcOver(conn), close: () => {} }
}

// ⛔ ONE CHANNEL PER CONNECTION, cached. Protomux keys a channel by protocol on a given stream, so
// opening a second one for the same protocol is a DUPLICATE CHANNEL — and that is what a person sees
// when they simply try again after a failed attempt: the first try leaves the connection open, the
// retry builds a fresh ProtomuxRPC over it, and the retry fails for a reason that has nothing to do
// with what they were doing.
//
// ⚠ A WeakMap, so a connection that goes away takes its channel with it rather than pinning it.
const channels = new WeakMap()

const rpcOver = (conn) => {
  const had = channels.get(conn)
  if (had) return had
  const rpc = new ProtomuxRPC(conn, { protocol: PROTOCOL, valueEncoding: c.json })
  channels.set(conn, rpc)
  // Dropped when the connection does, or a reconnect would hand back a channel on a dead stream.
  conn.once('close', () => { try { channels.delete(conn) } catch {} })
  return rpc
}

/**
 * The claim a peer sends. Pure — no network, no clock — so what gets signed is testable on its own.
 *
 * ⛔ `cred` is this device's credential, the same one that signs room writes. The bridge verifies the
 * chain from the identity ROOT down to this device, so a device that was never attested cannot
 * register a name, which is the correct answer: it cannot write to the feed either.
 */
export function buildClaim ({ nonce, username, feedKey, root, cred, invite = null }) {
  if (!root) throw new Error('this node cannot sign yet — link this device to its identity first')
  const proof = signAttested(claimBytes({ nonce, username, feedKey }), cred)
  const req = { username, root, feedKey, nonce, proof }
  if (invite) req.invite = invite
  return req
}

/** The release a peer sends. Separate bytes, so a claim proof can never be repurposed to burn a name. */
export function buildRelease ({ nonce, username, root, cred }) {
  if (!root) throw new Error('this node cannot sign yet — link this device to its identity first')
  return { username, root, nonce, proof: signAttested(releaseBytes({ nonce, username }), cred) }
}

/**
 * Ask a bridge to CARRY an identity this node holds, rather than to name one.
 *
 * ⚠ `id` is opaque to this daemon on purpose. It is a credential this node holds on some other
 * network; what it means there is the bridge's business, and boundary.test.js is what keeps that
 * true.
 */
export function buildFront ({ nonce, id, root, cred }) {
  if (!root) throw new Error('this node cannot sign yet — link this device to its identity first')
  return { id, root, nonce, proof: signAttested(frontBytes({ nonce, id }), cred) }
}

/**
 * Ask a bridge for what it OBSERVED while this node was not reachable.
 *
 * ⛔ WHY A PULL. A handset is not addressable — measured on ap.7rnx.net, a registered peer's outbox
 * hangs for the full timeout because the device holding that feed does not answer. A bridge with
 * something to tell us has nowhere to send it, so the direction has to be ours: we connect, we ask,
 * we decide what to keep. It is also the only direction the model allows — a bridge is not a writer
 * on this feed and must never author into it (hyper-social #61 §4).
 *
 * ⛔ THE CURSOR IS SIGNED, so it cannot be wound back in flight. That is also why a page needs its
 * own challenge: the nonce is single-use, and the bytes name the cursor this particular page asked
 * for. Collecting N pages is N challenges, deliberately.
 *
 * ⚠ THIS BUILDS THE REQUEST AND NOTHING ELSE. What arrives is a list of things OTHER people did, on
 * a network this daemon deliberately knows nothing about — boundary.test.js forbids naming one here.
 * Deciding what those rows mean, and writing them down, is the caller's job.
 */
export function buildCollect ({ nonce, username, since = 0, root, cred }) {
  if (!root) throw new Error('this node cannot sign yet — link this device to its identity first')
  return { username, root, since, nonce, proof: signAttested(collectBytes({ nonce, username, since }), cred) }
}

export default { connectBridge, buildClaim, buildRelease, buildFront, buildCollect, PROTOCOL }

// ── which bridges carry this node ────────────────────────────────────────────────────────────────
//
// ⛔ RELEASING NEEDS THE KEY AGAIN, and the name alone does not contain it. `name@host` says which
// bridge in human terms; connecting needs the 64-hex key, and asking a person to paste it a second
// time — to perform the one irreversible act in this flow — is how the wrong bridge gets asked.
//
// ⚠ LOCAL, not in `reach`. `reach` says where this identity PUBLISHES, which is a statement about
// the identity and replicates with it. Which key this node dialled to arrange that is operational
// detail: another device holding the same identity would resolve the same bridge by its own record.

import { join } from '#node:path'
import fs from '#node:fs'

const bridgesPath = (storage) => join(storage, 'bridges.json')

const readBridges = (storage) => {
  try { return JSON.parse(fs.readFileSync(bridgesPath(storage), 'utf8')) } catch { return {} }
}

/** Remember that `acct` is carried by `bridge`. Best-effort: losing this costs a re-paste, not a name. */
export function rememberBridge (storage, acct, bridge) {
  if (!acct || !bridge) return
  const all = readBridges(storage)
  all[acct] = String(bridge).replace(/^bridge:/, '').toLowerCase()
  try { fs.writeFileSync(bridgesPath(storage), JSON.stringify(all, null, 2) + '\n') } catch {}
}

/** Which bridge carries `acct`, or null. */
export function bridgeFor (storage, acct) {
  return (acct && readBridges(storage)[acct]) || null
}

export function forgetBridge (storage, acct) {
  const all = readBridges(storage)
  if (!(acct in all)) return
  delete all[acct]
  try { fs.writeFileSync(bridgesPath(storage), JSON.stringify(all, null, 2) + '\n') } catch {}
}

// ── the observation feed a bridge vouched for (hyper-social #82, I1 S4) ─────────────────────────────────────
//
// ⛔ VERIFIED AGAINST THE BRIDGE KEY THIS NODE CLAIMED WITH (operator decision O-2 b). A bridge writes what the
// outside world did to this user into a feed IT founded, and tells the node which feed and which founder root in
// the claim reply, signed with its swarm key. The grant is kept only if that signature holds for this user and
// generation; a node never holds — so never renders — a feed its bridge did not vouch for.
//
// ⚠ LOCAL, like bridges.json: which feed a bridge keeps for this node is operational detail, not something the
// identity publishes. Revocation is local too: forget the bridge, forget its feed.

import { verifyObserver } from 'favela-identity/lib/observer-grant.js'

const observersPath = (storage) => join(storage, 'observers.json')
const readObservers = (storage) => {
  try { return JSON.parse(fs.readFileSync(observersPath(storage), 'utf8')) || {} } catch { return {} }
}
const writeObservers = (storage, all) => {
  try { fs.writeFileSync(observersPath(storage), JSON.stringify(all, null, 2) + '\n') } catch {}
}

/** Every remembered bridge, as [acct, bridgeKeyHex]. */
export function bridges (storage) { return Object.entries(readBridges(storage)) }

/**
 * Keep the observer grant from a claim reply, if `bridgeKey` signed it for this user. → the stored record, or null.
 */
export function acceptObserver (storage, bridgeKey, body) {
  if (!body || !body.observer || !body.acct || !body.username) return null
  const key = String(bridgeKey || '').replace(/^bridge:/, '').toLowerCase()
  const o = body.observer
  const grant = { username: body.username, generation: body.generation, observationFeed: o.observationFeed, observerRoot: o.observerRoot }
  if (!verifyObserver(grant, o.sig, key)) return null
  const rec = {
    acct: body.acct,
    bridge: key,
    username: body.username,
    generation: Number(body.generation) | 0,
    observationFeed: String(o.observationFeed).toLowerCase(),
    observerRoot: String(o.observerRoot).toLowerCase()
  }
  const all = readObservers(storage)
  all[body.acct] = rec
  writeObservers(storage, all)
  return rec
}

/** Every observation feed this node holds on a bridge's word. */
export function observers (storage) { return Object.values(readObservers(storage)) }

export function forgetObserver (storage, acct) {
  const all = readObservers(storage)
  if (!(acct in all)) return
  delete all[acct]
  writeObservers(storage, all)
}

/**
 * Which [acct, bridgeKey] pairs a device should ask for an observation grant: its own bridges.json PLUS the bridge
 * the identity's reach names (replicated, so every device of the identity knows it), minus names already granted.
 */
export function claimTargets (storage, reach = {}) {
  const granted = new Set(observers(storage).map((o) => o.acct))
  const out = new Map()
  for (const [acct, key] of bridges(storage)) if (acct && key) out.set(acct, String(key).toLowerCase())
  if (reach && reach.fediverse && reach.fediverseBridge && !out.has(reach.fediverse)) {
    out.set(reach.fediverse, String(reach.fediverseBridge).toLowerCase())
  }
  return [...out.entries()].filter(([acct]) => !granted.has(acct))
}