OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / admin-auth.js

321 lines · javascript ref:
// admin-auth.js — who may operate this bridge.
//
// ⛔ THE SAME IDENTITY LAYER THE REST OF THE STACK USES, not a password. `Browsers` (favela-peer)
// holds the registry: an ANProto keypair proves itself against a single-use challenge, the FIRST one
// claims the instance, and every one after needs an invite. A bearer password would have been fewer
// moving parts and a seventh way to authenticate in one monorepo.
//
// ⛔ AND THE FIRST CLAIM IS GATED BY A BOOTSTRAP TOKEN. Plain first-claim TOFU is safe only while the
// surface is bound to loopback — browsers.js says so in its own header — and an operator page has to be
// reachable. The token is printed to the service log at startup, so it is a secret only whoever can
// read the log holds. See browsers.js for the gate and the claim latch.
//
// ⛔ HTTPS OR LOCALHOST, NOT PLAIN HTTP ON A PUBLIC PORT. static/anproto-client.js hashes with
// `crypto.subtle`, which browsers expose only in a secure context — its own comment says "works on
// localhost + HTTPS". So this listener stays on loopback and is published through the proxy that
// already terminates TLS for this host. Binding it to the network directly would serve a login page
// whose crypto is undefined in the browser that loaded it.

import crypto from 'bare-crypto'

export const COOKIE = 'ap_admin'

/**
 * Cookie -> the browser public key that proved itself. That is ALL it is.
 *
 * ⛔ EVERY REQUEST RE-ASKS whether that key is still allowed, so a revoke lands on the next request
 * rather than whenever something lapses. That is why there is no expiry to configure: the check is
 * live, and an expiry would only add a way to be logged out for no reason. Same shape favela-surface
 * uses, and for the same reasons.
 *
 * ⚠ In memory, so a restart means logging in again. Persisting them would make a bridge own durable
 * security state it has no other reason to hold.
 */
export class Sessions {
  constructor () { this._byId = new Map() }

  create (publicKey) {
    const id = crypto.randomBytes(32).toString('hex')
    this._byId.set(id, publicKey)
    return id
  }

  get (id) { return (id && this._byId.get(id)) || null }
  destroy (id) { if (id) this._byId.delete(id) }
  get size () { return this._byId.size }
}


/**
 * Did this request arrive on the loopback interface?
 *
 * ⛔ THE SOCKET, NEVER A HEADER. X-Forwarded-For and friends are supplied by the caller; trusting one
 * here would turn "you must be on this machine" into "you must claim to be", which is not a gate.
 *
 * ⚠ IPv6-mapped IPv4 (`::ffff:127.0.0.1`) is what a dual-stack listener actually reports for a v4
 * loopback connection, so matching only '127.0.0.1' refuses the ordinary case.
 */
export function isLoopback (address) {
  const a = String(address || '')
  return a === '127.0.0.1' || a === '::1' || a === '::ffff:127.0.0.1' || a.startsWith('127.')
}

export function parseCookies (header) {
  const out = {}
  for (const part of String(header || '').split(';')) {
    const i = part.indexOf('=')
    if (i === -1) continue
    out[part.slice(0, i).trim()] = part.slice(i + 1).trim()
  }
  return out
}

/**
 * The public key on this request, if it is still allowed to operate the bridge.
 *
 * ⛔ `browsers.status()` IS THE AUTHORITY, not the cookie. A cookie that maps to a revoked key must
 * stop working immediately — holding a session must never outlive the grant behind it.
 */
export async function currentAdmin (req, { sessions, browsers }) {
  const id = parseCookies(req.headers && req.headers.cookie)[COOKIE]
  const publicKey = sessions.get(id)
  if (!publicKey) return null
  try {
    const st = await browsers.status(publicKey)
    if (!st || !st.known || st.revoked) { sessions.destroy(id); return null }
  } catch { return null }
  return publicKey
}

const esc = (s) => String(s == null ? '' : s)
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')

export function loginPage ({ unclaimed = false, message = null, next = '/admin' } = {}) {
  const warn = message ? '<p class="warn">' + esc(message) + '</p>' : ''
  const intro = unclaimed
    ? '<p>Nobody operates this bridge yet. Claim it with the <strong>bootstrap token</strong> printed ' +
      'in the service log when it started.</p>' +
      '<p><label>Bootstrap token<br><input id="bootstrap" type="password" autocomplete="off" ' +
      'placeholder="32 hex characters"></label></p>'
    // ⛔ AN INVITE, NOT A SECOND BOOTSTRAP. The bootstrap token gates the FIRST claim and is spent the
    // moment it succeeds; browsers.js closes that door permanently and says "revoking the claimant does
    // NOT reopen it either". Re-arming it would reopen exactly the hole it exists to close. A second
    // admin arrives the way every admin after the first is meant to: an invite issued by one already
    // signed in, single-use, and expiring.
    : '<p>This browser signs in with the key it generated the first time you used it.</p>' +
      '<p><label>Invite <span class="who">— only if this browser has never signed in here</span><br>' +
      '<input id="invite" type="password" autocomplete="off" placeholder="leave empty to just sign in">' +
      '</label></p>'

  return `<!doctype html><meta charset="utf-8"><title>bridge · sign in</title>
<style>body{font:14px/1.5 system-ui,sans-serif;margin:0;padding:40px;max-width:520px;color:#111}
h1{font-size:20px}.warn{color:#a00}.who{color:#666}input{font:inherit;padding:5px;width:100%;box-sizing:border-box}
button{font:inherit;padding:6px 14px;margin-top:12px}code{font:12px ui-monospace,monospace;background:#f5f5f5;padding:1px 4px}</style>
<body data-next="${esc(next)}">
<h1>Sign in to this bridge</h1>${warn}${intro}
<button id="go">sign in</button>
<p class="who" id="msg"></p>
<p class="who">Your key: <code id="pk">…</code></p>
<script type="module">
import { ANProtoClient as an } from '/static/anproto-client.js'

// ⛔ THE KEY LIVES IN THIS BROWSER AND NOWHERE ELSE. It is not a device key and never enters the
// attestation chain — a browser cannot hold a writer core or verify a chain. It authenticates; the
// daemon writes.
const KEY = 'ap-admin-keypair'
let kp = localStorage.getItem(KEY)
// ⚠ Whether the key EXISTED matters: a key minted a moment ago has never been registered, so a silent
// attempt with it can only fail, and failing silently on first visit would look like a dead button.
const hadKey = !!kp
if (!kp) { kp = await an.gen(); localStorage.setItem(KEY, kp) }
const pk = an.getPublicKey(kp)
document.getElementById('pk').textContent = pk

const msg = document.getElementById('msg')
const NEXT = document.body.dataset.next || '/admin'
const post = (path, body) => fetch(path, {
  method: 'POST',
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams(body).toString()
})

async function signIn ({ silent } = {}) {
  const r = await post('/admin/challenge', {})
  const { challenge } = await r.json()
  // ⚠ The raw challenge — this client hashes it internally and emits the canonical wire format.
  // Hashing it here first would sign the wrong thing, verify, and then fail the hash check.
  const signature = await an.sign(challenge, kp)
  const boot = document.getElementById('bootstrap')
  const inv = document.getElementById('invite')
  const out = await post('/admin/session', {
    publicKey: pk, signature, challenge,
    // ⛔ Never sent on a silent attempt. An invite is single-use and consumed on redemption, so
    // replaying one automatically would burn it on a page load.
    bootstrap: silent ? '' : (boot ? boot.value : ''),
    invite: silent ? '' : (inv ? inv.value : '')
  })
  if (out.ok) { location.href = NEXT; return true }
  if (!silent) msg.textContent = await out.text()
  return false
}

document.getElementById('go').onclick = async () => {
  msg.textContent = 'signing…'
  try { await signIn({ silent: false }) } catch (e) { msg.textContent = (e && e.message) || String(e) }
}

// ⛔ SILENT RE-AUTH — the half that makes an in-memory session store usable. Sessions die with the
// process, so every deploy signs everybody out; pgv3 carries exactly this design and says why it is
// fine: "the browser still has its keypair in localStorage and the auth flow is silent". Without this
// the page is technically correct and reads as "I already logged in, why am I here again".
//
// ⚠ Only when this browser has a key AND the bridge is already claimed. An unclaimed bridge needs the
// bootstrap token typed in, and a brand-new browser has nothing to prove yet — both must show the form.
if (!document.getElementById('bootstrap') && hadKey) {
  msg.textContent = 'signing in…'
  try { if (!await signIn({ silent: true })) msg.textContent = '' } catch { msg.textContent = '' }
}
</script>`
}


/**
 * Who can operate this bridge — and how another browser is let in.
 *
 * ⛔ AN INVITE, NOT A SECOND BOOTSTRAP TOKEN. The bootstrap token exists to bind the FIRST claim to
 * whoever can read the service log, and browsers.js closes that door permanently: "there is no window
 * that reopens, and revoking the claimant does NOT reopen it either." Minting more of them would mean
 * re-arming TOFU on a surface that is now reachable from the network, which is the precise hole the
 * token was introduced to close. An invite does the thing that was actually wanted — let another
 * browser in — and does it as a single-use grant that expires, issued by an operator who is already
 * signed in and therefore already authorised.
 */
export function profilePage ({ me, browsers: list = [], invite = null, host = '' }) {
  const rows = list.length
    ? '<table><tr><th>key</th><th></th><th></th></tr>' + list.map((b) => {
      const isMe = b.publicKey === me
      return '<tr><td><code>' + esc(String(b.publicKey).slice(0, 24)) + '…</code>' +
        (isMe ? ' <strong>(this browser)</strong>' : '') + '</td>' +
        '<td>' + (b.revoked ? '<span class="warn">revoked</span>' : 'active') + '</td>' +
        // ⛔ A revoked identity stays LISTED. It is still registered — that is why revoking the
        // claimant does not reopen the instance — and hiding it would read as "removed", which is a
        // different and untrue thing.
        '<td>' + (b.revoked || isMe
          ? '<span class="who">' + (isMe ? 'you cannot revoke the browser you are using' : '—') + '</span>'
          : '<form method="post" action="/admin/revoke-browser"><input type="hidden" name="publicKey" value="' +
            esc(b.publicKey) + '"><button>revoke</button></form>') + '</td></tr>'
    }).join('') + '</table>'
    : '<p class="none">No operators registered.</p>'

  const issued = invite
    ? '<p>Give this to ONE browser. It is single-use and it expires.</p><p><code>' + esc(invite) + '</code></p>' +
      '<p class="who">They open this page, paste it into <strong>Invite</strong>, and sign in.</p>'
    : '<form method="post" action="/admin/invite-browser"><button>invite another browser</button></form>' +
      '<p class="who">This is how every operator after the first one gets in. The bootstrap token is ' +
      'not reissued — it gates the first claim only, and re-arming it would reopen the instance to ' +
      'whoever reached it first.</p>'

  return `<!doctype html><meta charset="utf-8"><title>operators · ${esc(host)}</title>
<style>body{font:14px/1.5 system-ui,sans-serif;margin:0;padding:24px;max-width:900px;color:#111}
h1{font-size:20px}h2{font-size:15px;margin-top:28px;border-bottom:1px solid #ddd;padding-bottom:4px}
table{border-collapse:collapse;width:100%;margin:8px 0}td,th{text-align:left;padding:5px 8px;border-bottom:1px solid #eee}
th{font-weight:600;color:#555;font-size:12px;text-transform:uppercase}code{font:12px ui-monospace,monospace;background:#f5f5f5;padding:1px 4px}
.who{color:#666}.warn{color:#a00}.none{color:#999;font-style:italic}button{font:inherit;padding:4px 10px}form{display:inline}</style>
<h1>Operators</h1>
<p class="who"><a href="/admin">← the bridge</a></p>
<h2>Browsers that may operate this bridge</h2>${rows}
<h2>Let another browser in</h2>${issued}
<h2>This browser</h2><p><code>${esc(me || '—')}</code></p>
<p class="who">The key lives in this browser and nowhere else. It is not a device key and never enters
the attestation chain — it authenticates, and the daemon writes.</p>
<form method="post" action="/admin/logout"><button>sign out</button></form>`
}

const text = (status, body) => ({ status, headers: { 'content-type': 'text/plain; charset=utf-8' }, body })
const html = (status, body) => ({ status, headers: { 'content-type': 'text/html; charset=utf-8' }, body })

/**
 * The sign-in routes. Returns null when the path is not one of ours, so the caller can route on.
 */
export async function authRoutes (req, ctx) {
  const { browsers, sessions } = ctx
  const path = req.path || '/'

  if (req.method === 'GET' && path === '/admin/login') {
    // ⛔ ONLY A LOCAL PATH. `next` comes off the wire, and echoing an absolute URL into a redirect is
    // an open redirect — a link to this bridge that lands somebody somewhere else.
    const wanted = String((req.query && req.query.next) || '/admin')
    const next = wanted.startsWith('/') && !wanted.startsWith('//') ? wanted : '/admin'
    return html(200, loginPage({ unclaimed: await browsers.unclaimed(), next }))
  }

  if (req.method === 'POST' && path === '/admin/challenge') {
    const { challenge } = browsers.challenge()
    return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ challenge }) }
  }

  if (req.method === 'POST' && path === '/admin/session') {
    const f = req.form || {}
    const proof = { publicKey: f.publicKey, signature: f.signature, challenge: f.challenge }
    try {
      // ⛔ CLAIM OR AUTHENTICATE — never both, and the instance decides which. Offering a "claim"
      // button after the first one would be a second door onto an instance that is already owned.
      if (await browsers.unclaimed()) await browsers.claim({ ...proof, bootstrap: f.bootstrap || null })
      // ⛔ REDEEM ONLY WHEN ONE IS OFFERED. An invite is single-use and consumed on redemption, so
      // spending one on a browser that could simply authenticate would burn it for nothing.
      else if (f.invite) await browsers.redeem({ ...proof, token: f.invite })
      else await browsers.authenticate(proof)
    } catch (err) {
      // ⚠ The daemon's own words: it knows whether the token was wrong, the key unknown, the
      // challenge expired, or the instance already claimed. A generic "sign-in failed" would make
      // every one of those look like a typo.
      return text(403, (err && err.message) || String(err))
    }
    const id = sessions.create(f.publicKey)
    return {
      status: 200,
      // ⚠ HttpOnly so a page cannot read it; SameSite=Strict so it never rides a cross-site request.
      headers: { 'set-cookie': COOKIE + '=' + id + '; HttpOnly; SameSite=Strict; Path=/' },
      body: 'ok'
    }
  }

  if (req.method === 'GET' && path === '/admin/profile') {
    const me = await currentAdmin(req, { sessions, browsers })
    if (!me) return { status: 303, headers: { location: '/admin/login' }, body: '' }
    return html(200, profilePage({ me, browsers: await browsers.list(), host: (ctx && ctx.config && ctx.config.host) || '' }))
  }

  if (req.method === 'POST' && path === '/admin/invite-browser') {
    const me = await currentAdmin(req, { sessions, browsers })
    if (!me) return { status: 303, headers: { location: '/admin/login' }, body: '' }
    const { token } = await browsers.invite()
    return html(200, profilePage({ me, browsers: await browsers.list(), invite: token, host: (ctx && ctx.config && ctx.config.host) || '' }))
  }

  if (req.method === 'POST' && path === '/admin/revoke-browser') {
    const me = await currentAdmin(req, { sessions, browsers })
    if (!me) return { status: 303, headers: { location: '/admin/login' }, body: '' }
    const target = String((req.form && req.form.publicKey) || '')
    // ⛔ NOT YOURSELF. Revoking the browser you are holding locks the bridge to whatever other
    // operators exist — and if there are none, to nobody at all, with no way back in because the
    // bootstrap token gates only the first claim and this instance is already claimed.
    if (target && target !== me) await browsers.revoke(target)
    return { status: 303, headers: { location: '/admin/profile' }, body: '' }
  }

  // ⛔ RECOVERY IS NOT HERE — it is lib/admin-control.js, on a unix socket. An earlier version put it
  // on this surface gated by the socket's remote address, which is the right intent and a weaker gate:
  // loopback is reachable by ANY process or user on the machine, a unix socket only by one that can
  // open its path. Both mean "you are on this box"; only one means "you are the operator". Leaving the
  // HTTP door in place beside the socket would simply leave the weaker one open.
  if (req.method === 'POST' && path === '/admin/logout') {
    sessions.destroy(parseCookies(req.headers && req.headers.cookie)[COOKIE])
    return { status: 303, headers: { location: '/admin/login', 'set-cookie': COOKIE + '=; Max-Age=0; Path=/' }, body: '' }
  }

  return null
}

export default { Sessions, authRoutes, currentAdmin, loginPage, profilePage, parseCookies, isLoopback, COOKIE }