OverviewFilesCommitsIssuesDocsReleasesPeers

favela-identity / lib / admission.js

149 lines · javascript ref:
// admission.js — proving that a caller CONTROLS the identity it is speaking for.
//
// ⛔ SHARED, because there are two bridges and there must not be two ceremonies. ap-bridge asks it
// about NAMES and atproto-bridge asks it about CARRYING an identity; both questions are the same
// question underneath, and a second copy would be the one that forgot the nonce is single-use. It
// lives here for the reason this package exists at all — its own header: "EXTRACTED SO THERE IS ONE
// COPY".
//
// ⛔ WHY THIS IS A SEPARATE FILE. `registrations.js` holds the registry and says, in its own header,
// that it deliberately does not do this: "it does not verify the chain proof: that is
// favela-identity's `readAttested`… 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." No route had a challenge, so that sentence described a thing
// that did not exist, and `readAttested` appeared in this package only inside that comment.
//
// What that cost: an identity root is PUBLIC. `claim()` accepted any 64-hex string, so anyone could
// take a name under anyone's identity; and `release()` compared a bare root, so anyone could BURN
// someone's actor — permanently, because the generation counter deliberately makes a reused name a
// different actor and there is no way back.
//
// ⛔ A CHALLENGE IS REQUIRED, NOT DECORATION. `readAttested` is a pure function of the proof's bytes
// — no disk, no network, NO CLOCK — which is exactly the property that lets it run inside an
// autobase apply. A verifier with no clock cannot detect replay. Freshness therefore has to come
// from something the bridge issues and remembers, and there is nowhere else to put it.
//
// ⛔ AND THE REQUEST IS INSIDE THE SIGNED BYTES. A proof covering only the nonce could be captured
// and replayed to claim a DIFFERENT name under the signer's root — consent proven, control proven,
// and the wrong name registered. Binding username (and feed, on a claim) is what stops that.
//
// ⚠ TOTAL. This runs on a request path fed by strangers, so every path returns { ok, error } and
// nothing throws: `readAttested` is already guarded that way for the same reason.

import crypto from 'bare-crypto'
import b4a from 'b4a'
import { readAttested } from './attestation.js'
import { claimBytes, releaseBytes, frontBytes, collectBytes } from './bridge-claim.js'

const HEX64 = /^[0-9a-f]{64}$/i
// Deliberately the same shape registrations.js enforces, so bytes signed here can never describe a
// name the registry would reject.
const NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/


const DEFAULT_TTL = 120_000 // two minutes — a round trip, not a session

export class Admission {
  constructor ({ ttlMs = DEFAULT_TTL, now = Date.now } = {}) {
    this._ttl = ttlMs
    this._now = now
    this._open = new Map() // nonce -> expiresAt
  }

  /**
   * Mint a single-use challenge.
   *
   * ⛔ From a CSPRNG. `registrations.js`'s invite token used `Math.random()`, which on an invite-only
   * bridge is the entire admission gate; a guessable nonce here would be the same mistake one layer
   * down. bare-crypto is already a dependency.
   */
  challenge () {
    this._sweep()
    const nonce = b4a.toString(crypto.randomBytes(32), 'hex')
    const expiresAt = this._now() + this._ttl
    this._open.set(nonce, expiresAt)
    return { nonce, expiresAt }
  }

  /** The exact bytes a claimant must sign. Public, because the caller has to produce them too. */
  claimBytes (params) { return claimBytes(params) }

  /** The exact bytes a releaser must sign. */
  releaseBytes (params) { return releaseBytes(params) }

  verifyClaim ({ nonce, username, feedKey, root, proof } = {}) {
    if (badName(username)) return fail('bad username')
    if (feedKey !== null && feedKey !== undefined && !HEX64.test(String(feedKey))) return fail('bad feed key')
    return this._verify(nonce, root, proof, () => this.claimBytes({ nonce, username, feedKey }))
  }

  verifyRelease ({ nonce, username, root, proof } = {}) {
    if (badName(username)) return fail('bad username')
    return this._verify(nonce, root, proof, () => this.releaseBytes({ nonce, username }))
  }

  /**
   * Asking a bridge to CARRY an identity, rather than to name one.
   *
   * ⚠ `id` is opaque here, and deliberately: this package does not know what network names it, and
   * the bridge doing the verifying is the only thing that needs to. What is checked is the same
   * question in both cases — does this caller control the identity it is speaking for.
   */
  verifyFront ({ nonce, id, root, proof } = {}) {
    if (typeof id !== 'string' || !id || id.length > 256 || id.includes(':::')) return fail('bad identifier')
    return this._verify(nonce, root, proof, () => frontBytes({ nonce, id }))
  }

  frontBytes (params) { return frontBytes(params) }

  /**
   * Asking a bridge for what it has HELD for a name.
   *
   * ⚠ THIS PROVES ONE HALF ONLY. It answers "does this caller control this root" — it says nothing
   * about whether that root holds that name, because this package has no registry and must not grow
   * one. The bridge owns the second half and it is the half that matters here: roots are public, so a
   * caller who proved control of their own while naming somebody else's username would otherwise be
   * handed that person's entire inbound social graph. See ap-bridge's `collect.js`.
   */
  verifyCollect ({ nonce, username, since, root, proof } = {}) {
    if (badName(username)) return fail('bad username')
    return this._verify(nonce, root, proof, () => this.collectBytes({ nonce, username, since }))
  }

  collectBytes (params) { return collectBytes(params) }

  /**
   * ⛔ THE NONCE IS BURNED WHETHER OR NOT THE PROOF VERIFIES. Consuming only on success would leave a
   * live challenge for an attacker to keep trying proofs against; consuming always costs a legitimate
   * caller one extra round trip in the rare case they got it wrong, and costs an attacker everything.
   */
  _verify (nonce, root, proof, bytesOf) {
    if (typeof nonce !== 'string' || !this._open.has(nonce)) return fail('unknown or expired challenge')
    const expiresAt = this._open.get(nonce)
    this._open.delete(nonce)
    if (this._now() > expiresAt) return fail('unknown or expired challenge')
    if (typeof root !== 'string' || !HEX64.test(root)) return fail('identity root required')

    // ⛔ The root is pinned INSIDE verify rather than compared afterwards. attestation.js: "a
    // comparison the caller has to remember to write is a comparison someone eventually forgets."
    // A non-null return means exactly: this root attested this device, and that device signed these
    // bytes. Neither half is trusted separately.
    const seen = readAttested(proof, bytesOf(), root.toLowerCase())
    if (!seen) return fail('proof does not attest that identity')
    return { ok: true, root: seen.root, device: seen.device }
  }

  /** Expired challenges are not evidence of anything — drop them rather than grow a map for ever. */
  _sweep () {
    const now = this._now()
    for (const [nonce, expiresAt] of this._open) if (now > expiresAt) this._open.delete(nonce)
  }

  get open () { return this._open.size }
}

const badName = (s) => typeof s !== 'string' || !NAME.test(s)
const fail = (error) => ({ ok: false, error })

export default { Admission }