// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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 }