// 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')
})