OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-bridge / pds / lib / fronting.js

152 lines · javascript ref:
// fronting.js — asking this membrane to CARRY an identity, without an operator in the loop.
//
// ⛔ WHAT WAS MISSING. `allowFront()` only UN-DENIES a firehose key that `dropFront({forget})` denied;
// it does not allow-list anything. `_isCared(did)` is `_fronted || _peers || _opts`, and a DID enters
// `_fronted` through `add()` — reachable only from `cfg.frontedDids` at boot or from the owner-gated
// control socket. So being carried was an operator action on the bridge host, and a peer holding a
// perfectly good identity had no way to ask. That is the same gap ap-bridge had for names.
//
// ⛔ WHAT THE PROOF IS AND IS NOT FOR. This membrane is KEYLESS: it resolves a DID's verify key from
// PLC and refuses any commit that does not verify against it. So a caller naming somebody else's
// identity cannot make this bridge serve a forgery — it would serve that person's real, signed repo.
// The proof is therefore not what protects the identity; the signature chain already does. It bounds
// ABUSE: carrying an identity costs a topic join, a corestore namespace and replication for ever,
// and "anyone may spend that, anonymously, unboundedly" is not a policy anyone chose.
//
// ⚠ Which is why refusing is cheap and being wrong is cheap. An identity wrongly carried serves
// correct bytes to whoever asks; an identity wrongly refused just is not fronted here yet.

import ProtomuxRPC from 'protomux-rpc'
import c from 'compact-encoding'
import b4a from 'b4a'
import { Admission } from 'favela-identity/lib/admission.js'

// ⚠ THE SAME PROTOCOL STRING as ap-bridge's registration door, deliberately: it is one ceremony with
// one set of verbs, and a peer should not have to know which kind of bridge it is talking to before
// it can ask what that bridge does.
export const PROTOCOL = 'favela/bridge-register/1'

export { Admission }

/**
 * The verbs, as plain async functions — testable without a DHT. The transport below is thin, and the
 * rules are not in it.
 */
const HANDLE_NAME = /^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$/

export function frontHandlers ({ peers, admission, onFronted = null, policy = 'open', userDomain = '.7rnx.net', pdsUrl = null, log = () => {} }) {
  return {
    // ⛔ THE OFFER, not just a yes/no. A peer needs to know the handle suffix and the endpoint BEFORE
    // it commits, because accepting is a permanent append to a global log — it cannot be undone if
    // the answer turns out to be something else.
    policy: async () => ({ policy, proof: !!admission, userDomain, pdsUrl }),

    challenge: async () => {
      if (!admission) throw new Error('fronting is not configured on this bridge')
      return admission.challenge()
    },

    /**
     * ⚠ IDEMPOTENT. `add()` on an already-fronted identity returns the existing peer, and a peer
     * that retries after a dropped connection must not be told it did something wrong.
     */
    front: async (params = {}) => {
      if (!peers) return { status: 503, body: { error: 'this bridge fronts nothing' } }
      if (!admission) return { status: 503, body: { error: 'fronting is not configured on this bridge' } }
      if (policy !== 'open') return { status: 403, body: { error: 'this bridge does not take requests' } }

      const ok = admission.verifyFront({ nonce: params.nonce, id: params.id, root: params.root, proof: params.proof })
      if (!ok.ok) return { status: 403, body: { error: ok.error } }

      // ⛔ THE HANDLE IS GRANTED HERE, because the membrane owns the namespace under its domain and
      // is the only thing that can answer `<handle>/.well-known/atproto-did`. A peer that chose its
      // own handle would be claiming a name on somebody else's domain.
      const name = String(params.name || '').trim().toLowerCase()
      if (name && !HANDLE_NAME.test(name)) return { status: 400, body: { error: 'that is not a usable handle' } }
      const handle = name ? name + userDomain : null

      // ⛔ Taken by somebody ELSE is a refusal; taken by this same identity is a retry.
      if (handle && peers.resolveHandle) {
        const holder = peers.resolveHandle(handle)
        if (holder && holder !== params.id) return { status: 409, body: { error: 'taken' } }
      }

      const already = peers.isFronted ? peers.isFronted(params.id) : false
      try {
        // ⛔ No verifyKey supplied — the lazy allow-set add. The key resolves from PLC when something
        // actually asks for the repo, which is the keyless property: this bridge never holds one.
        //
        // ⚠ The handle goes in with it: `add()` records it, and that is what makes the well-known
        // route answer for this name. Granting a handle and not recording it would be a name that
        // resolves nowhere.
        peers.add(params.id, handle ? { handle } : {})
      } catch (err) {
        return { status: 500, body: { error: (err && err.message) || String(err) } }
      }

      // ⛔ PERSISTED BY THE CALLER, or a restart silently unfronts everyone who asked. `frontedDids`
      // is config and this is not config — it is what peers requested, and it has to outlive a boot
      // the same way the handle map does.
      if (onFronted) {
        try {
          await onFronted({ id: params.id, root: ok.root, handle })
        } catch (err) {
          // ⚠ It IS fronted at this point. Reporting failure would send a peer away from something
          // that is working; reporting plain success would promise it survives a restart.
          return { status: 202, body: { id: params.id, handle, pdsUrl, fronted: true, durable: false, reason: String(err.message || err) } }
        }
      }

      log({ msg: 'fronting', id: params.id, handle, root: ok.root, already })
      // ⛔ THE HANDLE AND THE ENDPOINT COME BACK, because the peer's next act is a PLC update putting
      // exactly these two values on-chain. hpp's mint names this step: the handle and service
      // endpoint "arrive LATER, as a PLC update op, when an invite from a bridge is accepted".
      return { status: 200, body: { id: params.id, handle, pdsUrl, fronted: true, durable: true, already } }
    }
  }
}

/**
 * Serve those verbs to anyone who connects on this bridge's own key.
 *
 * ⛔ ON THE EXISTING SWARM. This membrane already runs one to reach the peers it fronts; a second
 * would be a second NAT mapping and a second set of connections for nothing.
 *
 * ⛔ `{ server: true, client: true }`, both load-bearing — hyper-proto-peer measured it: a member
 * that joins `client: false` never sends outbound holepunch packets, so its NAT mapping never opens
 * and a peer punching from the other side is dropped. A NAT'd handset asking a NAT'd bridge host to
 * carry it is exactly that case.
 */
export function serveFronting ({ swarm, log = () => {}, ...deps }) {
  if (!swarm) throw new Error('serveFronting: a swarm is required')
  const handlers = frontHandlers({ ...deps, log })

  const onconnection = (conn) => {
    let rpc
    try {
      rpc = new ProtomuxRPC(conn, { protocol: PROTOCOL, valueEncoding: c.json })
    } catch { return }
    for (const [name, fn] of Object.entries(handlers)) {
      rpc.respond(name, async (params) => {
        try {
          return await fn(params || {})
        } catch (err) {
          // ⚠ Answered, never left as a dead channel: a peer that gets no reply cannot tell a refusal
          // from a bridge that is down, and those need different actions.
          return { status: 500, body: { error: (err && err.message) || String(err) } }
        }
      })
    }
  }

  swarm.on('connection', onconnection)
  const topic = swarm.keyPair.publicKey
  const disc = swarm.join(topic, { server: true, client: true })

  return {
    key: b4a.toString(topic, 'hex'),
    ready: () => disc.flushed(),
    close: () => { try { swarm.off('connection', onconnection) } catch {} }
  }
}