OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / test / register-route.test.js

224 lines · javascript ref:
// register-route.test.js — asking a bridge for a name, automatically.
//
// The point: a peer should be able to request a name and get one, with no operator in the loop when
// the bridge is open. Closed bridges still work the same way; the peer just needs the invite the
// operator sent it.

import test from 'brittle'
import b4a from 'b4a'
import IdentityKey from 'keet-identity-key'
import crypto from 'hypercore-crypto'
import { signAttested } from 'favela-identity/lib/attestation.js'
import { handleRequest as route } from '../lib/router.js'
import { MemoryRegistrations } from '../lib/registrations.js'
import { Admission } from '../lib/admission.js'

// ⛔ A REAL IDENTITY, not a placeholder root. These tests used `'a'.repeat(64)` and passed, which is
// exactly the defect: an identity root is PUBLIC, so accepting one as proof of holding it let anyone
// claim a name under anyone's identity. The ceremony below is what a peer actually performs.
let ME = null
async function me () {
  if (ME) return ME
  const idk = await IdentityKey.from({ mnemonic: IdentityKey.generateMnemonic() })
  const root = idk.identityKeyPair
  const kp = crypto.keyPair()
  const proof = await IdentityKey.bootstrap({ identity: root }, kp.publicKey)
  ME = { rootHex: b4a.toString(root.publicKey, 'hex'), cred: { keyPair: kp, proof: b4a.toString(proof, 'base64') } }
  return ME
}

// ⛔ A SECOND IDENTITY, because `me()` is memoised and one identity cannot express the difference
// between "the holder asks again" and "somebody else wants this name". The taken-name test below used
// the same root twice and so asserted the former while describing the latter — which is how refusing
// the actual holder survived as intended behaviour. Re-claiming by the holder is now admitted (it is
// the only way back for a device that lost its local record); a stranger is still refused, and that
// is what this exists to prove.
async function stranger () {
  if (STRANGER) return STRANGER
  const idk = await IdentityKey.from({ mnemonic: IdentityKey.generateMnemonic() })
  const kp = crypto.keyPair()
  const proof = await IdentityKey.bootstrap({ identity: idk.identityKeyPair }, kp.publicKey)
  STRANGER = { rootHex: b4a.toString(idk.identityKeyPair.publicKey, 'hex'), cred: { keyPair: kp, proof: b4a.toString(proof, 'base64') } }
  return STRANGER
}
let STRANGER = null

const ctxFor = (policy) => ({
  config: { host: 'ap.7rnx.net', username: 'primary' },
  registrations: new MemoryRegistrations({ policy }),
  admission: new Admission(),
  log: () => {}
})
const post = (body) => ({ method: 'POST', path: '/ap/register', headers: {}, query: {}, body: JSON.stringify(body) })

/** The claim a peer sends: ask for a challenge, sign the exact request, send both. */
async function claimBody (ctx, { username, feedKey = 'f'.repeat(64), invite = null, as = null }) {
  const who = as || await me()
  const { nonce } = ctx.admission.challenge()
  const proof = signAttested(ctx.admission.claimBytes({ nonce, username, feedKey }), who.cred)
  const body = { username, root: who.rootHex, feedKey, nonce, proof }
  if (invite) body.invite = invite
  return body
}

test('★★ an open bridge grants a name on request — nobody in the loop', async (t) => {
  const ctx = ctxFor('open')
  const res = await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx)

  t.is(res.status, 200)
  const doc = JSON.parse(res.body)
  t.is(doc.username, 'ashatron')
  t.is(doc.actor, 'https://ap.7rnx.net/ap/users/ashatron/actor', 'and it says where the actor now lives')
  t.is((await ctx.registrations.get('ashatron')).root, (await me()).rootHex)
})

test('★ a peer can ask whether a bridge is open before trying', async (t) => {
  const open = await route({ method: 'GET', path: '/ap/register', headers: {}, query: {} }, ctxFor('open'))
  t.is(JSON.parse(open.body).policy, 'open')
  const shut = await route({ method: 'GET', path: '/ap/register', headers: {}, query: {} }, ctxFor('invite'))
  t.is(JSON.parse(shut.body).policy, 'invite', 'so it knows to expect an invite instead of guessing')
})

test('⛔ a closed bridge refuses, and says what is needed', async (t) => {
  const ctx = ctxFor('invite')
  const res = await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx)

  t.is(res.status, 403)
  t.is(JSON.parse(res.body).error, 'invite required')
})

test('★ with the invite the operator sent, the same request works', async (t) => {
  const ctx = ctxFor('invite')
  const invite = await ctx.registrations.invite()

  const res = await route(post(await claimBody(ctx, { username: 'ashatron', invite })), ctx)
  t.is(res.status, 200)
})

test('⛔ a taken name is refused, not silently reassigned', async (t) => {
  const ctx = ctxFor('open')
  await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx)
  const res = await route(post(await claimBody(ctx, { username: 'ashatron', as: await stranger() })), ctx)

  t.is(res.status, 409)
  t.is(JSON.parse(res.body).error, 'taken')
  t.is((await ctx.registrations.get('ashatron')).root, (await me()).rootHex, 'the holder is unchanged')
})

test('★ the holder asking again is admitted, which is the only way back from a lost record', async (t) => {
  const ctx = ctxFor('open')
  await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx)
  const res = await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx)

  t.is(res.status, 200)
  t.is(JSON.parse(res.body).acct, 'ashatron@ap.7rnx.net', 'and told the name it came back for')
})

test('⛔ a bridge with no registry does not silently accept', async (t) => {
  const res = await route(post({ username: 'x', root: 'a'.repeat(64) }), { config: { host: 'h' }, log: () => {} })
  t.is(res.status, 404, 'registration is off unless the operator turned it on')
})

test('⛔ garbage in is refused, not guessed at', async (t) => {
  const ctx = ctxFor('open')
  t.is((await route({ ...post({}), body: 'not json' }, ctx)).status, 400)
  // ⚠ 403, not 400: the proof gate runs BEFORE the registry examines the request's shape. An
  // unproven caller is turned away without its request being inspected at all, which is the correct
  // order — the bridge owes a stranger no diagnosis of a request it will not consider.
  t.is((await route(post({ username: 'x' }), ctx)).status, 403, 'a name with no identity behind it')
  t.is((await route({ ...post({ username: 'x', root: 'a'.repeat(64) }), method: 'PUT' }, ctx)).status, 405)
})

test('★ the holder can release their name over the same endpoint', async (t) => {
  const ctx = ctxFor('open')
  await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx)
  const who = await me()

  const del = (body) => route({ method: 'DELETE', path: '/ap/register', headers: {}, query: {}, body: JSON.stringify(body) }, ctx)

  // ⚠ A stranger now fails on the PROOF, not on a string comparison — which is the difference between
  // "you are not the holder" and "you cannot demonstrate that you are".
  t.is((await del({ username: 'ashatron', root: 'b'.repeat(64) })).status, 403, 'a stranger cannot')

  const { nonce } = ctx.admission.challenge()
  const proof = signAttested(ctx.admission.releaseBytes({ nonce, username: 'ashatron' }), who.cred)
  t.is((await del({ username: 'ashatron', root: who.rootHex, nonce, proof })).status, 200, 'the holder can')
  t.absent(await ctx.registrations.get('ashatron'))
})

// ⛔ The reuse protection, end to end over HTTP: the same name comes back as a NEW generation, so a
// federated reference to the last holder cannot resolve to the next one.
test('⛔ a re-claimed name is a new generation', async (t) => {
  const ctx = ctxFor('open')
  const who = await me()
  await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx)

  const { nonce } = ctx.admission.challenge()
  const proof = signAttested(ctx.admission.releaseBytes({ nonce, username: 'ashatron' }), who.cred)
  await route({ method: 'DELETE', path: '/ap/register', headers: {}, query: {}, body: JSON.stringify({ username: 'ashatron', root: who.rootHex, nonce, proof }) }, ctx)

  const again = await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx)
  t.is(JSON.parse(again.body).generation, 2)
})

// ⛔ THE REGRESSION THIS FILE EXISTED WITHOUT. Every test above used to pass while sending no proof
// at all, because there was none to send: an identity root is public and the route accepted it as
// evidence of holding it. These pin the refusals, so the gate cannot quietly stop applying.
test('⛔⛔ a claim with no proof is refused — a public root is not evidence', async (t) => {
  const ctx = ctxFor('open')
  const who = await me()
  const res = await route(post({ username: 'ashatron', root: who.rootHex, feedKey: 'f'.repeat(64) }), ctx)

  t.is(res.status, 403)
  t.is(JSON.parse(res.body).error, 'unknown or expired challenge')
  t.is(await ctx.registrations.get('ashatron'), null, 'and nothing was registered')
})

test('⛔⛔ a claim proved by a DIFFERENT identity cannot take your name', async (t) => {
  const ctx = ctxFor('open')
  const victim = await me()

  // An attacker with their own valid identity, naming the victim's public root.
  const idk = await IdentityKey.from({ mnemonic: IdentityKey.generateMnemonic() })
  const kp = crypto.keyPair()
  const attacker = { keyPair: kp, proof: b4a.toString(await IdentityKey.bootstrap({ identity: idk.identityKeyPair }, kp.publicKey), 'base64') }

  const { nonce } = ctx.admission.challenge()
  const proof = signAttested(ctx.admission.claimBytes({ nonce, username: 'ashatron', feedKey: 'f'.repeat(64) }), attacker)
  const res = await route(post({ username: 'ashatron', root: victim.rootHex, feedKey: 'f'.repeat(64), nonce, proof }), ctx)

  t.is(res.status, 403)
  t.is(JSON.parse(res.body).error, 'proof does not attest that identity')
})

// ⛔ RELEASE BURNS THE ACTOR. The generation counter makes a reused name a different actor on
// purpose, so there is no way back — and this compared a bare root until now.
test('⛔⛔ a release with no proof cannot burn somebody\'s actor', async (t) => {
  const ctx = ctxFor('open')
  await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx)
  const who = await me()

  const res = await route({
    method: 'DELETE', path: '/ap/register', headers: {}, query: {},
    body: JSON.stringify({ username: 'ashatron', root: who.rootHex })
  }, ctx)

  t.is(res.status, 403)
  t.ok(await ctx.registrations.get('ashatron'), 'the name is still held')
})

test('★ a peer can fetch a challenge, and each one is single-use', async (t) => {
  const ctx = ctxFor('open')
  const res = await route({ method: 'GET', path: '/ap/register/challenge', headers: {}, query: {} }, ctx)
  t.is(res.status, 200)
  const { nonce, expiresAt } = JSON.parse(res.body)
  t.ok(/^[0-9a-f]{64}$/.test(nonce), 'a nonce to sign')
  t.ok(expiresAt > Date.now(), 'with a deadline')

  const who = await me()
  const proof = signAttested(ctx.admission.claimBytes({ nonce, username: 'ashatron', feedKey: 'f'.repeat(64) }), who.cred)
  const body = { username: 'ashatron', root: who.rootHex, feedKey: 'f'.repeat(64), nonce, proof }
  t.is((await route(post(body), ctx)).status, 200, 'the first use works')
  t.is((await route(post({ ...body, username: 'other' }), ctx)).status, 403, 'and it cannot be used again')
})