OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / registrations.js

213 lines · javascript ref:
// registrations.js — who may have a name on this bridge.
//
// ⛔ A BRIDGE IS AN OWNED RESOURCE, NOT A COMMONS. That is the whole reason this differs from
// favela's NameRegistry, where `opAuthorised` binds release to the claim's own publisher — so a
// squatted name is permanent, which hyper-social #59 §3 found and filed as pear-git #43. A bridge
// runs on somebody's domain: the operator owns the namespace under it, and gets the arbiter the
// commons deliberately cannot have. First come first served; the holder OR the operator may release.
//
// ⛔ AND A RELEASE BURNS THE IDENTITY, not just the string. AP object ids are `/ap/users/<name>/…`.
// If alice registers, federates fifty posts, releases, and bob then claims `alice`, bob inherits her
// actor URI and every object id — and every remote server that cached her now follows him. That is
// impersonation by name reuse. The `generation` counter is what stops it: it increments on every
// re-claim, ids derive from it, and a reused name therefore cannot collide with its predecessor's
// audience. The tombstone is kept for the same reason a record's deletion is kept — absence would
// make "never registered" and "registered then released" identical, and the second is the one a
// federated peer may still hold references into.
//
// Policy (hyper-social #65 §8, preserved rather than relaxed):
//
//   open    request + chain proof              → registered
//   invite  bridge issues invite; invite + proof → registered
//
// §8's rule is that admission needs CONSENT and CONTROL, and neither substitutes for the other:
// "an invite without control lets an invited user claim someone else's identity; control without an
// invite lets anyone use your bridge." Open registration does not drop a half — it replaces the
// PER-USER invite with a STANDING POLICY consent. Control is proved identically either way.
//
// ⚠ This module holds the REGISTRY. It does not verify the chain proof: that is
// `favela-identity`'s `readAttested`, which is pure and has no filesystem, network or clock, and it
// belongs at the route where the challenge lives. Here a `root` is required and its SHAPE checked;
// proving the caller controls it is the caller's job and must happen before claim() is reached.

import fs from 'bare-fs'
import crypto from 'bare-crypto'
import b4a from 'b4a'
import { dirname } from 'bare-path'

const HEX64 = /^[0-9a-f]{64}$/i
// A username becomes a path segment in a public URI, so it is deliberately narrower than a favela
// handle: lowercase, no dots (they read as domains), no separators to escape.
const NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/

const badName = (s) => typeof s !== 'string' || !NAME.test(s)

class BaseRegistrations {
  constructor ({ policy = 'invite' } = {}) {
    // ⛔ Defaults to CLOSED. A bridge that opens itself because nobody said otherwise is a bridge
    // whose operator did not choose to be open.
    this.policy = policy === 'open' ? 'open' : 'invite'
  }

  async get (username) {
    if (badName(username)) return null
    return (await this._read()).names[username] || null
  }

  /** The tombstone for a released name, or null. Kept — see the header. */
  async released (username) {
    if (badName(username)) return null
    return (await this._read()).burned[username] || null
  }

  async list () { return Object.values((await this._read()).names) }

  /**
   * Every name that has been RELEASED, with its tombstone.
   *
   * ⛔ Operators need to see these, because a burned name is not a free name: the generation counter
   * makes a reused name a DIFFERENT actor so it cannot inherit the previous holder's audience, and an
   * operator looking at only the live list would read a burned name as simply absent.
   */
  async listBurned () { return Object.values((await this._read()).burned) }

  /** Mint a single-use invite. The operator's act; only meaningful when the policy is `invite`. */
  async invite () {
    const state = await this._read()
    const token = randomToken()
    state.invites[token] = { mintedAt: Date.now() }
    await this._write(state)
    return token
  }

  /**
   * Claim a name.
   *
   * ⛔ `root` is REQUIRED under both policies. Open registration relaxes CONSENT, never CONTROL —
   * a bridge that let anyone claim any name without an identity behind it would be handing out
   * public addresses on the operator's domain to nobody in particular.
   */
  async claim ({ username, root, feedKey = null, invite = null } = {}) {
    if (badName(username)) return { ok: false, error: 'bad username' }
    if (typeof root !== 'string' || !HEX64.test(root)) return { ok: false, error: 'identity root required' }

    const state = await this._read()
    const existing = state.names[username]
    if (existing) {
      // ⛔ THE HOLDER ASKING AGAIN IS NOT A SQUATTER. Control was proved before this call (admit.js
      // verifies the chain proof first), so a matching root IS the holder — and answering `taken` to
      // the person who holds it is how a name becomes unrecoverable. Measured on a handset: the name
      // was claimed, the device's local record never got written (admit.js's 202 omitted `acct`), and
      // from then on re-claiming said "taken" while the release page refused to open because it needs
      // the record that was never written. Held, invisible, and no way back from the device.
      //
      // ⛔ THE GENERATION MUST NOT MOVE. It is what makes a REUSED name a different actor, so bumping
      // it for the same holder would orphan every object id already federated under the old one —
      // the impersonation-by-name-reuse guard turned on the very person it protects.
      if (root && existing.root === String(root).toLowerCase()) {
        // ⚠ The feed key is refreshed, because a person can hold one name across a replaced device
        // and the bridge must hold the feed it was LAST told to hold.
        if (feedKey && existing.feedKey !== feedKey) {
          existing.feedKey = feedKey
          await this._write(state)
        }
        return { ok: true, username: existing.username, generation: existing.generation }
      }
      return { ok: false, error: 'taken' }
    }

    if (this.policy !== 'open') {
      if (!invite || !state.invites[invite]) return { ok: false, error: 'invite required' }
      delete state.invites[invite] // ⛔ single use, consumed before the claim can fail on anything else
    }

    // ⛔ The generation is what makes a reused name a DIFFERENT actor. It counts every holder this
    // name has ever had, so ids derived from it never collide with a previous holder's.
    const prior = state.burned[username]
    const generation = (prior ? prior.generation : 0) + 1

    // ⛔ THE FEED KEY IS PERSISTED. Without it a restart re-admits the name with nothing to hold —
    // the actor exists and serves nothing, which is worse than not serving at all because it looks
    // registered. Measured: the first deploy logged `admitted … held:false` for exactly this.
    state.names[username] = { username, root: root.toLowerCase(), feedKey: feedKey || null, generation, registeredAt: Date.now() }
    await this._write(state)
    return { ok: true, username, generation }
  }

  /**
   * Release a name — by its holder, or by the operator.
   *
   * ⛔ TWO AUTHORITIES, deliberately. The holder may leave; the operator may reclaim, because the
   * names live on their domain and a bridge without an arbiter has permanent squatters. That is the
   * gap #59 §3 recorded in favela's registry, which cannot be fixed there for the same reason it
   * can be here: a commons has no owner and a bridge does.
   */
  async release (username, { root = null, operator = false } = {}) {
    if (badName(username)) return { ok: false, error: 'bad username' }
    const state = await this._read()
    const held = state.names[username]
    if (!held) return { ok: false, error: 'not registered' }
    if (!operator && (!root || held.root !== String(root).toLowerCase())) {
      return { ok: false, error: 'not yours to release' }
    }

    delete state.names[username]
    // Remembered, not forgotten: a federated peer may still hold references into what this name
    // addressed, and the next holder must not inherit them.
    state.burned[username] = { ...held, releasedAt: Date.now(), by: operator ? 'operator' : 'holder' }
    await this._write(state)
    return { ok: true, username }
  }
}

/**
 * ⛔ A CSPRNG, because on an `invite` bridge this token IS the admission gate.
 *
 * This was four draws from `Math.random()`, which is not cryptographically secure and is seeded and
 * advanced predictably — so the one secret standing between a stranger and a name on somebody's
 * domain was guessable. bare-crypto was already a dependency of this package.
 */
function randomToken () {
  return b4a.toString(crypto.randomBytes(16), 'hex')
}

export class Registrations extends BaseRegistrations {
  constructor ({ path, policy } = {}) {
    super({ policy })
    if (!path) throw new Error('Registrations: a path is required')
    this._path = path
    this._state = null
  }

  async _read () {
    if (this._state) return this._state
    try {
      const raw = JSON.parse(fs.readFileSync(this._path, 'utf8'))
      this._state = { names: raw.names || {}, burned: raw.burned || {}, invites: raw.invites || {} }
    } catch {
      // Absent or corrupt: start empty rather than refusing to boot, matching followers.js. ⚠ On a
      // bridge this is louder than it looks — an unreadable registry means every registered actor
      // 404s until it is restored, so the file is written atomically below.
      this._state = { names: {}, burned: {}, invites: {} }
    }
    return this._state
  }

  async _write (state) {
    this._state = state
    fs.mkdirSync(dirname(this._path), { recursive: true })
    const tmp = this._path + '.tmp'
    fs.writeFileSync(tmp, JSON.stringify(state, null, 2))
    fs.renameSync(tmp, this._path)
  }
}

/** Same interface, no disk. For tests. */
export class MemoryRegistrations extends BaseRegistrations {
  constructor (opts) { super(opts); this._state = { names: {}, burned: {}, invites: {} } }
  async _read () { return this._state }
  async _write (state) { this._state = state }
}

export default { Registrations, MemoryRegistrations }