OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / lib / browsers.js

295 lines · javascript ref:
// Browser identities — the web-UI half of auth.
//
// ── Why a browser is not a device ─────────────────────────────────────────────────────────────
//
// A device writes to the feed: it holds a writer core and a chain proof to the identity root. A
// browser cannot. It cannot run hypercore, cannot hold a writer core, and cannot even verify a
// chain — `keet-identity-key` runs on sodium-native, which is not a thing in a browser.
//
// So a browser is a CONTROLLER: it authenticates to a daemon and the daemon writes. Its key never
// enters the attestation chain, which is also why it needs no scope field — a chain proof has none
// to give (`{version, epoch, identity, chain, data?}` and nothing else), and attesting a browser
// key would hand localStorage the power to mint devices.
//
// ── One implementation of the wire format ─────────────────────────────────────────────────────
//
// The `anproto` package, which is what pear-git-v3 depends on. NOT one of the six-plus copies of
// the same protocol scattered through this monorepo — three of which still carry a greedy
// timestamp parse that eats the hash's first character whenever it happens to be a digit
// (~1 in 6 messages; measured 59/300 against hyper-identity's copy before it was pinned).
//
// The canonical package has always split on exactly 13 digits (`lib/an.js:119`), which is why the
// forge never saw it. Depending on the package is how we avoid becoming the seventh copy.
//
// The registry is `hyper-identity2` — the autobee binding — because this daemon runs autobee and
// must not carry autobase beside it.
//
// ── The gate ──────────────────────────────────────────────────────────────────────────────────
//
// `autoRegister: false`. An unknown key is refused, full stop. A browser gets in exactly one way:
// a ONE-TIME INVITE, generated by a human at the CLI or an already-authenticated web-UI.
//
// ⛔ The invite EXPIRES; the identity it creates does NOT. Those are different objects. An invite
// that never expired would be a standing hole; a browser that already proved itself stays until it
// is revoked. Only the token's HASH reaches the log, so holding the log never yields an invite, and
// single-use is enforced inside apply — two browsers racing one invite cannot both win.
//
// ── ⛔ TOFU: the FIRST browser claims the instance ─────────────────────────────────────────────
//
// With no browser registered, the first key to prove itself is accepted without an invite. That is
// pear-git-v3's shape — `envelope-ctx.js` creates a user for any verified envelope and gives the
// first one admin — and it exists so a fresh install is usable before it can mint an invite.
//
// ⛔ It closes permanently on the first claim. Every browser after that needs an invite; there is no
// window that reopens, and revoking the claimant does NOT reopen it either — a revoked identity is
// still a registered one, which is exactly why the listing keeps it.
//
// ⚠ The safety of this rests ENTIRELY on the surface binding localhost. Whoever reaches the page
// first owns the instance, so exposing it before claiming it hands it to whoever is on the network.
// pear-git-v3 carries the same property. It is a first-run convenience, not an authorisation model.
//
// ⚠ And it races: two browsers arriving together can both read "nobody registered". The window is
// one round trip on a loopback socket, and pgv3 has the same race, but it is real and not papered
// over here.

import { IdentityRegistry } from 'hyper-identity2'
import { an, Challenges } from 'anproto'

/**
 * Compare two secrets without leaking their prefix through timing.
 *
 * ⚠ A plain `===` on a token checked by anyone who can reach the port is a byte-at-a-time oracle.
 * Length is compared first and deliberately leaks only the length, which a token of fixed size does
 * not hide anyway.
 */
function timingEqual (a, b) {
  if (a.length !== b.length) return false
  let diff = 0
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
  return diff === 0
}

// The feed owner's user record. One identity at a time, so one user — every browser grant links to
// this one, and revoking a browser never touches the account.
const OWNER = 'owner'

export class Browsers {
  constructor (corestore, opts = {}) {
    // ⛔ ITS OWN NAMESPACE. Two Autobees on one Corestore derive the same local writer and DEADLOCK
    // — they do not error, they hang (measured 2026-09-06, favela-peer/test/replicate.test.js).
    // The feed is already namespaced; this must be too.
    this._store = corestore.namespace('identity')
    this.registry = new IdentityRegistry(this._store, {})
    // Keyed by the challenge string itself, single-use, TTL-swept. No ids to correlate.
    this.challenges = new Challenges(opts.challenges || {})
    this._owner = null

    // ⛔ THE BOOTSTRAP TOKEN — what makes TOFU safe on a surface that is NOT loopback.
    //
    // Plain TOFU is a first-run convenience whose safety rests entirely on the surface binding
    // localhost (see the header): whoever reaches the page first owns the instance. A bridge's
    // operator page has to be reachable from the network, so "first to arrive" would mean the first
    // stranger who finds the port. A token the caller prints to its own log at startup is a secret
    // only the operator can read, and requiring it for the FIRST claim binds that claim to them
    // without inventing an authorisation model.
    //
    // ⚠ null means NOT REQUIRED, which is the loopback case and stays the default — favela-peer's
    // surface is bound to 127.0.0.1 and claims through dispatch before a listener exists.
    this._bootstrap = typeof opts.bootstrap === 'string' && opts.bootstrap ? opts.bootstrap : null

    // ⛔ CLAIMS ARE SERIALISED. `claim` reads `unclaimed()` and then writes, and the header admits
    // the gap: "two browsers arriving together can both read 'nobody registered'". On loopback that
    // is one round trip; on a public port it is a race an attacker can drive deliberately. One latch
    // makes the check-and-register atomic within this process, which is the only process there is.
    this._claiming = Promise.resolve()
  }

  async ready () {
    await this.registry.ready()
    // Idempotent: the owner is created once and found thereafter.
    this._owner = await this.registry.getUser(OWNER).catch(() => null)
    if (!this._owner) {
      this._owner = await this.registry.createUser({ userId: OWNER, name: 'owner' })
    }
    return this
  }

  /** A fresh single-use challenge for the browser to sign. */
  challenge () {
    return { challenge: this.challenges.generate() }
  }

  /**
   * A one-time invite. This is the gate for an arbitrary browser.
   *
   * Returns the plaintext token, which is the only copy — the log holds its hash. Hand it to the
   * person at the browser out of band.
   */
  async invite (opts = {}) {
    const { token, expiresAt } = await this.registry.createLinkToken(OWNER, opts)
    return { token, expiresAt }
  }

  /**
   * Redeem an invite: prove the key, then attach it.
   *
   * ⛔ The signature is checked BEFORE the token is spent. Otherwise a wrong signature would burn
   * a single-use invite, which turns a typo into a denial of service.
   */
  async redeem ({ token, publicKey, signature, challenge }) {
    if (!token) throw new Error('redeem needs an invite token')
    await this._proveKey({ publicKey, signature, challenge })
    await this.registry.registerIdentity({
      identityId: publicKey, method: 'web-identity', publicKey, credentials: { publicKey }
    })
    await this.registry.consumeLinkToken(token, publicKey)
    return { publicKey }
  }

  /**
   * Authenticate an already-registered browser. Every request, not once per session.
   *
   * There is no bearer token to steal or expire: possession of the key is re-proven each time, and
   * the provider checks revocation on every call, so a revoke takes effect on the next request.
   */
  async authenticate ({ publicKey, signature, challenge }) {
    await this._proveKey({ publicKey, signature, challenge })

    const identity = await this.registry.getIdentityByPublicKey(publicKey)
    // ⛔ autoRegister is OFF by construction: an unknown key is refused here, and the ONLY way to
    // become known is to redeem a one-time invite. Proving you hold a key is not permission.
    if (!identity) throw new Error('unknown identity — redeem an invite first')
    if (await this.registry.isRevoked(identity.identityId)) throw new Error('identity has been revoked')

    return { identityId: identity.identityId, method: 'web-identity', credentials: { publicKey } }
  }

  /**
   * Is this browser known, and is it still allowed?
   *
   * ⛔ No signature. This is for a caller that has ALREADY verified possession once and now holds a
   * session — it re-checks standing on every request so a revoke lands immediately, without making
   * a no-JS page sign each navigation. Possession is proven at login; permission is checked always.
   */
  async status (publicKey) {
    const identity = await this.registry.getIdentityByPublicKey(publicKey)
    if (!identity) return { known: false, revoked: false }
    return { known: true, revoked: !!(await this.registry.isRevoked(identity.identityId)) }
  }

  /**
   * Has anyone claimed this instance yet?
   *
   * Reported so the login page can offer the claim rather than demand a token nobody can mint yet.
   * It reveals only whether a browser is registered — no key, no name.
   */
  async unclaimed () {
    return (await this.list()).length === 0
  }

  /**
   * Claim an unclaimed instance — TOFU. See the header.
   *
   * ⛔ Refused the moment ANY browser is registered, revoked ones included. Revocation must not
   * reopen the door, or "retire that browser" would silently mean "let the next stranger in".
   */
  async claim ({ publicKey, signature, challenge, bootstrap = null }) {
    await this._proveKey({ publicKey, signature, challenge })

    // ⛔ BEFORE THE LATCH AND BEFORE ANY WRITE. A wrong token must cost nothing and change nothing.
    if (this._bootstrap && !timingEqual(String(bootstrap || ''), this._bootstrap)) {
      throw new Error('this instance needs its bootstrap token to be claimed — it is printed in the ' +
        'service log the first time it starts, and only the operator can read that')
    }

    // ⛔ SERIALISED — see the constructor. Without this the check below and the write under it are a
    // read-then-write that two claimants can interleave.
    const previous = this._claiming
    let done
    this._claiming = new Promise((resolve) => { done = resolve })
    try {
      await previous
      if (!(await this.unclaimed())) {
        throw new Error('this instance is already claimed — ask its owner for an invite')
      }
      return await this._register(publicKey)
    } finally {
      done()
    }
  }

  async _register (publicKey) {
    await this.registry.registerIdentity({
      identityId: publicKey, method: 'web-identity', publicKey, credentials: { publicKey }
    })
    // ⛔ Registering an identity is not attaching it. `redeem` gets the link from
    // `consumeLinkToken`; a claim has no token, so it must link explicitly — otherwise the key
    // verifies, the instance still reads as unclaimed, and the next browser claims it too.
    await this.registry.linkIdentityToUser(publicKey, OWNER, { verified: true })
    return { publicKey, claimed: true }
  }

  // ⛔ The profile is NOT here. It was, and that was wrong: this registry is local to a node and
  // never replicates, so a name and picture kept here are invisible to everyone who reads the feed
  // — no name, no avatar, nothing to search. It lives in the feed as a `profile/self` record; see
  // peer.js. This class holds browser credentials, which are correctly local.

  /** Retire a browser. The only way out — there is no expiry. */
  async revoke (publicKey) {
    await this.registry.revokeIdentity(publicKey)
    return { revoked: publicKey }
  }

  /** Registered browsers, with their revocation state named rather than filtered away. */
  async list () {
    const result = await this.registry.getUserWithIdentities(OWNER).catch(() => null)
    const identities = (result && result.identities) || []
    const out = []
    for (const i of identities) {
      if (i.method !== 'web-identity') continue
      out.push({ publicKey: i.publicKey || i.identityId, revoked: !!(await this.registry.isRevoked(i.identityId)) })
    }
    return out
  }

  /**
   * Prove possession of the key against a live challenge.
   *
   * The package gives `open` (verify the signature, return the signed string) and `parse` (split it
   * into timestamp and hash on exactly 13 digits). Composing them here rather than reimplementing
   * either is the whole point of depending on the package.
   */
  async _proveKey ({ publicKey, signature, challenge }) {
    if (!publicKey || !signature || !challenge) {
      throw new Error('a browser must present { publicKey, signature, challenge }')
    }
    if (!this.challenges.has(challenge)) throw new Error('invalid or expired challenge')

    let opened
    try { opened = await an.open(signature) } catch { throw new Error('invalid signature') }
    if (!opened) throw new Error('invalid signature')

    // ⛔ The envelope EMBEDS the signer's public key, as its first 44 characters — that is what
    // `an.open` verifies against. Checking it against the CLAIMED key is what stops a valid
    // signature by one key being presented as another's.
    //
    // ⚠ Not `an.publicKey()`: that reads a 132-char KEYPAIR, not a signature, and refuses anything
    // else. Two different objects whose public halves are both 44 leading characters.
    if (signature.slice(0, 44) !== publicKey) {
      throw new Error('signature public key does not match the claimed key')
    }

    const { hash } = an.parse(opened)
    if (hash !== await an.hash(challenge)) throw new Error('signature does not cover this challenge')

    // ⛔ Consumed only after every check passes, so a bad signature cannot burn a live challenge.
    if (!this.challenges.consume(challenge)) throw new Error('challenge already used')
  }

  async close () {
    try { await this.registry.close() } catch {}
  }
}

export default Browsers