// 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