OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / test / browsers.test.js

235 lines · javascript ref:
// 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')
})