// Browser identities — the gate for an arbitrary browser.
//
// A browser is not a device. It cannot write to the feed, cannot hold a writer core, and cannot
// verify a chain (keet-identity-key runs on sodium-native). It authenticates to a daemon and the
// daemon writes. So its key never enters the attestation chain — which is also why it needs no
// scope field, and why attesting it as a device would be an escalation rather than a simplification.
//
// The gate is a ONE-TIME INVITE. `autoRegister: false`, so an unknown key is refused outright.
//
// ⛔ The invite expires; the identity it creates does not. Different objects. An invite that never
// expired would be a standing hole; a browser that already proved itself stays until revoked.
import test from 'brittle'
import Corestore from 'corestore'
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import { an } from 'anproto'
import { Browsers } from '../lib/browsers.js'
const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-browsers-'))
async function open () {
const b = new Browsers(new Corestore(tmp()))
await b.ready()
return b
}
// A browser: an ANProto keypair, and the ability to answer a challenge.
//
// ⚠ `an` is the canonical package — the same one pear-git-v3 depends on — not one of the six-plus
// copies of this wire format in the monorepo. The test signs the way a real browser does.
async function browser () {
const keypair = await an.gen()
return {
publicKey: an.publicKey(keypair),
async answer (b) {
const { challenge } = b.challenge()
return { challenge, signature: await an.sign(await an.hash(challenge), keypair) }
}
}
}
test('⛔ an uninvited browser is refused — autoRegister is off', async (t) => {
const b = await open()
t.teardown(() => b.close())
const w = await browser()
await t.exception(b.authenticate({ publicKey: w.publicKey, ...(await w.answer(b)) }),
/redeem an invite first/i, 'proving you hold a key is not the same as being allowed in')
})
test('★ an invite admits exactly one browser', async (t) => {
const b = await open()
t.teardown(() => b.close())
const w = await browser()
const { token } = await b.invite()
await b.redeem({ token, publicKey: w.publicKey, ...(await w.answer(b)) })
const who = await b.authenticate({ publicKey: w.publicKey, ...(await w.answer(b)) })
t.is(who.credentials.publicKey, w.publicKey, 'and afterwards it authenticates on its own key')
t.is(who.method, 'web-identity')
})
test('⛔ an invite is single-use', async (t) => {
const b = await open()
t.teardown(() => b.close())
const first = await browser()
const second = await browser()
const { token } = await b.invite()
await b.redeem({ token, publicKey: first.publicKey, ...(await first.answer(b)) })
await t.exception(b.redeem({ token, publicKey: second.publicKey, ...(await second.answer(b)) }),
/consumed/i, 'the second browser cannot ride the same invite')
})
test('⛔ a bad signature does not BURN the invite', async (t) => {
const b = await open()
t.teardown(() => b.close())
const w = await browser()
const impostor = await browser()
const { token } = await b.invite()
// A signature by the wrong key, presented as w's. The canonical envelope embeds the signer, so
// this is caught — and it must be caught BEFORE the token is spent, or a typo becomes a denial
// of service against the person holding the invite.
const bad = await impostor.answer(b)
await t.exception(b.redeem({ token, publicKey: w.publicKey, ...bad }), /does not match/i)
await b.redeem({ token, publicKey: w.publicKey, ...(await w.answer(b)) })
t.ok(await b.authenticate({ publicKey: w.publicKey, ...(await w.answer(b)) }),
'the invite survived the failed attempt and still works')
})
test('⛔ a challenge is single-use — a signature cannot be replayed', async (t) => {
const b = await open()
t.teardown(() => b.close())
const w = await browser()
const { token } = await b.invite()
const answer = await w.answer(b)
await b.redeem({ token, publicKey: w.publicKey, ...answer })
// The same (challengeId, signature) pair again. If challenges were reusable, anyone who saw one
// request could repeat it forever.
await t.exception(b.authenticate({ publicKey: w.publicKey, ...answer }),
/challenge/i, 'a consumed challenge is gone')
})
test('★ revocation takes effect on the NEXT request — there is no session to wait out', async (t) => {
const b = await open()
t.teardown(() => b.close())
const w = await browser()
const { token } = await b.invite()
await b.redeem({ token, publicKey: w.publicKey, ...(await w.answer(b)) })
t.ok(await b.authenticate({ publicKey: w.publicKey, ...(await w.answer(b)) }), 'in')
await b.revoke(w.publicKey)
await t.exception(b.authenticate({ publicKey: w.publicKey, ...(await w.answer(b)) }),
/revoked/i, 'the key is re-proven every request, so a revoke lands immediately')
})
test('the listing NAMES revoked browsers rather than hiding them', async (t) => {
const b = await open()
t.teardown(() => b.close())
const keep = await browser()
const drop = await browser()
for (const w of [keep, drop]) {
const { token } = await b.invite()
await b.redeem({ token, publicKey: w.publicKey, ...(await w.answer(b)) })
}
await b.revoke(drop.publicKey)
const rows = await b.list()
const byKey = Object.fromEntries(rows.map((r) => [r.publicKey, r.revoked]))
t.is(byKey[keep.publicKey], false)
// A revoked browser that vanished from the list would look like one that was never invited, and
// those are different facts — the same distinction the record log makes everywhere else.
t.is(byKey[drop.publicKey], true, 'revoked, and still listed')
})
test('⛔ an expired invite is refused', async (t) => {
const b = await open()
t.teardown(() => b.close())
const w = await browser()
const { token } = await b.invite({ ttlMs: -1 }) // already in the past
await t.exception(b.redeem({ token, publicKey: w.publicKey, ...(await w.answer(b)) }),
/expired/i, 'the INVITE expires — the identity it would have created does not')
})
test('status re-checks standing without a signature', async (t) => {
const b = await open()
t.teardown(() => b.close())
const w = await browser()
t.alike(await b.status(w.publicKey), { known: false, revoked: false },
'an unknown key is not secretly "known but fine"')
const { token } = await b.invite()
await b.redeem({ token, publicKey: w.publicKey, ...(await w.answer(b)) })
t.alike(await b.status(w.publicKey), { known: true, revoked: false })
await b.revoke(w.publicKey)
// ⛔ This is what a session-holding page calls on EVERY request. Possession was proven once at
// login; permission is checked always, so a revoke does not wait for anything to lapse.
t.alike(await b.status(w.publicKey), { known: true, revoked: true })
})
// ── TOFU: the first browser claims the instance ────────────────────────────────────────────────
test('★ the FIRST browser claims an unclaimed instance without an invite', async (t) => {
const b = await open()
t.teardown(() => b.close())
const first = await browser()
t.ok(await b.unclaimed(), 'a fresh instance is unclaimed')
await b.claim({ publicKey: first.publicKey, ...(await first.answer(b)) })
const who = await b.authenticate({ publicKey: first.publicKey, ...(await first.answer(b)) })
t.is(who.credentials.publicKey, first.publicKey, 'and it is in, with no token anywhere')
t.absent(await b.unclaimed(), 'the instance is claimed now')
})
test('⛔ the door closes on the first claim — the second browser needs an invite', async (t) => {
const b = await open()
t.teardown(() => b.close())
const first = await browser()
const second = await browser()
await b.claim({ publicKey: first.publicKey, ...(await first.answer(b)) })
await t.exception(b.claim({ publicKey: second.publicKey, ...(await second.answer(b)) }),
/already claimed/i, 'proving a key is not claiming an instance someone already holds')
// The supported path still works.
const { token } = await b.invite()
await b.redeem({ token, publicKey: second.publicKey, ...(await second.answer(b)) })
t.ok(await b.authenticate({ publicKey: second.publicKey, ...(await second.answer(b)) }))
})
test('⛔ revoking the claimant does NOT reopen the door', async (t) => {
const b = await open()
t.teardown(() => b.close())
const first = await browser()
const stranger = await browser()
await b.claim({ publicKey: first.publicKey, ...(await first.answer(b)) })
await b.revoke(first.publicKey)
// A revoked identity is still a REGISTERED one — which is why the listing keeps it rather than
// filtering it away. If revocation reopened TOFU, "retire that browser" would quietly mean
// "let the next stranger in".
t.absent(await b.unclaimed(), 'still claimed')
await t.exception(b.claim({ publicKey: stranger.publicKey, ...(await stranger.answer(b)) }),
/already claimed/i)
})
test('⛔ a claim still requires proving the key', async (t) => {
const b = await open()
t.teardown(() => b.close())
const w = await browser()
const impostor = await browser()
const bad = await impostor.answer(b)
await t.exception(b.claim({ publicKey: w.publicKey, ...bad }), /does not match/i,
'TOFU trusts the FIRST key, not an unverified claim about one')
t.ok(await b.unclaimed(), 'and a failed claim leaves the instance unclaimed')
})