OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge commits / 5547c0a

registration proves CONTROL, which it never did
`registrations.js` requires a `root` and checks only its SHAPE. Its own header says why that is not enough — "it does not verify the chain proof: that is favela-identity's readAttested… it belongs at the route where the challenge lives" — and no route had a challenge, so readAttested appeared in this package only inside that comment. An identity root is PUBLIC. So anyone could claim a name under anyone's identity, and release() compared a bare root, so anyone could BURN someone's actor — permanently, because the generation counter deliberately makes a reused name a different actor. hyper-social #65 §8 says admission needs CONSENT and CONTROL and that neither substitutes for the other; the consent half was built and the control half was asserted. lib/admission.js is that half. A challenge is required rather than decorative: readAttested is a pure function of the proof's bytes with no clock, which is what lets it run inside an apply, and a verifier with no clock cannot detect replay. The request is bound INTO the signed bytes, or a captured proof could be replayed to claim a different name under the signer's root. ⛔ The gate is unconditional. Written first as `if (ctx.admission)`, the entire suite passed — because the route tests supplied none, so the gate never ran. That is a hole hiding behind a green suite, so the tests now perform the real ceremony and three of them pin the refusals. Also: randomToken used Math.random(), and on an invite bridge that token IS the admission gate. ⚠ Breaking for callers: a claim or release now needs a challenge and a proof. Existing registrations are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5547c0ac233f498dfb113e824f8e7dfc083fdd5e
by plankatron <fleeky@gmail.com> · 2026-09-11T16:12:19.000Z

Changed files (6) +433 -19

index.js +10 -0
……@@ -108,10 +108,16 @@ async function main () {
108108 // ids fix resolution; distinct key material is what makes the separation real, since a signature
109109 // is what proves who acted.
110110 const userKeys = registrations ? new UserKeys({ dir: join(dataDir, 'user-keys') }) : null
111 // ⛔ The control half of admission. registrations.js holds the registry and says in its own header
112 // that proving control "belongs at the route where the challenge lives" — this is that challenge.
113 // Without it an identity root, which is public, was accepted as proof of holding it.
114 const { Admission } = await import('./lib/admission.js')
115 const admission = registrations ? new Admission() : null
111116
112117 let seq = 0
113118 const ctx = {
114119 registrations,
120 admission,
115121 config,
116122 publicKeyPem,
117123 followers,
……@@ -487,6 +493,10 @@ async function main () {
487493 // OTA + control surface. Best-effort: if it cannot come up we keep federating (see lib/control.js).
488494 const { startControl } = await import('./lib/control.js')
489495 await startControl({
496 // ⛔ The operator's half of registration. `/ap/register` handles the HOLDER's side; minting the
497 // token that gates it is not something an HTTP caller should be able to assert, which is the same
498 // split the release path already makes.
499 onInvite: registrations ? (() => registrations.invite()) : null,
490500 udsPath: process.env.AP_SOCK || join(config.dataDir, 'svc.sock'),
491501 name: process.env.PGH_APP_NAME || 'ap-bridge',
492502 version: pkgVersion(),
lib/admission.js +121 -0
……@@ -0,0 +1,121 @@
1// admission.js — proving that a caller CONTROLS the identity it claims a name under.
2//
3// ⛔ WHY THIS IS A SEPARATE FILE. `registrations.js` holds the registry and says, in its own header,
4// that it deliberately does not do this: "it does not verify the chain proof: that is
5// favela-identity's `readAttested`… it belongs at the route where the challenge lives. Here a `root`
6// is required and its SHAPE checked; proving the caller controls it is the caller's job and must
7// happen before claim() is reached." No route had a challenge, so that sentence described a thing
8// that did not exist, and `readAttested` appeared in this package only inside that comment.
9//
10// What that cost: an identity root is PUBLIC. `claim()` accepted any 64-hex string, so anyone could
11// take a name under anyone's identity; and `release()` compared a bare root, so anyone could BURN
12// someone's actor — permanently, because the generation counter deliberately makes a reused name a
13// different actor and there is no way back.
14//
15// ⛔ A CHALLENGE IS REQUIRED, NOT DECORATION. `readAttested` is a pure function of the proof's bytes
16// — no disk, no network, NO CLOCK — which is exactly the property that lets it run inside an
17// autobase apply. A verifier with no clock cannot detect replay. Freshness therefore has to come
18// from something the bridge issues and remembers, and there is nowhere else to put it.
19//
20// ⛔ AND THE REQUEST IS INSIDE THE SIGNED BYTES. A proof covering only the nonce could be captured
21// and replayed to claim a DIFFERENT name under the signer's root — consent proven, control proven,
22// and the wrong name registered. Binding username (and feed, on a claim) is what stops that.
23//
24// ⚠ TOTAL. This runs on a request path fed by strangers, so every path returns { ok, error } and
25// nothing throws: `readAttested` is already guarded that way for the same reason.
26
27import crypto from 'bare-crypto'
28import b4a from 'b4a'
29import { readAttested } from 'favela-identity/lib/attestation.js'
30
31const HEX64 = /^[0-9a-f]{64}$/i
32// Deliberately the same shape registrations.js enforces, so bytes signed here can never describe a
33// name the registry would reject.
34const NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/
35
36// ⚠ VERSIONED AND PREFIXED. The prefix keeps a claim proof from ever verifying as a release proof;
37// the version means a future change to what is bound can be introduced without silently accepting
38// both shapes. Colons are safe separators: a username cannot contain one (NAME), and the nonce and
39// feed key are hex.
40const CLAIM = 'favela-bridge:v1:claim'
41const RELEASE = 'favela-bridge:v1:release'
42
43const DEFAULT_TTL = 120_000 // two minutes — a round trip, not a session
44
45export class Admission {
46 constructor ({ ttlMs = DEFAULT_TTL, now = Date.now } = {}) {
47 this._ttl = ttlMs
48 this._now = now
49 this._open = new Map() // nonce -> expiresAt
50 }
51
52 /**
53 * Mint a single-use challenge.
54 *
55 * ⛔ From a CSPRNG. `registrations.js`'s invite token used `Math.random()`, which on an invite-only
56 * bridge is the entire admission gate; a guessable nonce here would be the same mistake one layer
57 * down. bare-crypto is already a dependency.
58 */
59 challenge () {
60 this._sweep()
61 const nonce = b4a.toString(crypto.randomBytes(32), 'hex')
62 const expiresAt = this._now() + this._ttl
63 this._open.set(nonce, expiresAt)
64 return { nonce, expiresAt }
65 }
66
67 /** The exact bytes a claimant must sign. Public, because the caller has to produce them too. */
68 claimBytes ({ nonce, username, feedKey }) {
69 return b4a.from([CLAIM, nonce, username, feedKey || ''].join(':'), 'utf8')
70 }
71
72 /** The exact bytes a releaser must sign. No feed: releasing says nothing about where a feed is. */
73 releaseBytes ({ nonce, username }) {
74 return b4a.from([RELEASE, nonce, username].join(':'), 'utf8')
75 }
76
77 verifyClaim ({ nonce, username, feedKey, root, proof } = {}) {
78 if (badName(username)) return fail('bad username')
79 if (feedKey !== null && feedKey !== undefined && !HEX64.test(String(feedKey))) return fail('bad feed key')
80 return this._verify(nonce, root, proof, () => this.claimBytes({ nonce, username, feedKey }))
81 }
82
83 verifyRelease ({ nonce, username, root, proof } = {}) {
84 if (badName(username)) return fail('bad username')
85 return this._verify(nonce, root, proof, () => this.releaseBytes({ nonce, username }))
86 }
87
88 /**
89 * ⛔ THE NONCE IS BURNED WHETHER OR NOT THE PROOF VERIFIES. Consuming only on success would leave a
90 * live challenge for an attacker to keep trying proofs against; consuming always costs a legitimate
91 * caller one extra round trip in the rare case they got it wrong, and costs an attacker everything.
92 */
93 _verify (nonce, root, proof, bytesOf) {
94 if (typeof nonce !== 'string' || !this._open.has(nonce)) return fail('unknown or expired challenge')
95 const expiresAt = this._open.get(nonce)
96 this._open.delete(nonce)
97 if (this._now() > expiresAt) return fail('unknown or expired challenge')
98 if (typeof root !== 'string' || !HEX64.test(root)) return fail('identity root required')
99
100 // ⛔ The root is pinned INSIDE verify rather than compared afterwards. attestation.js: "a
101 // comparison the caller has to remember to write is a comparison someone eventually forgets."
102 // A non-null return means exactly: this root attested this device, and that device signed these
103 // bytes. Neither half is trusted separately.
104 const seen = readAttested(proof, bytesOf(), root.toLowerCase())
105 if (!seen) return fail('proof does not attest that identity')
106 return { ok: true, root: seen.root, device: seen.device }
107 }
108
109 /** Expired challenges are not evidence of anything — drop them rather than grow a map for ever. */
110 _sweep () {
111 const now = this._now()
112 for (const [nonce, expiresAt] of this._open) if (now > expiresAt) this._open.delete(nonce)
113 }
114
115 get open () { return this._open.size }
116}
117
118const badName = (s) => typeof s !== 'string' || !NAME.test(s)
119const fail = (error) => ({ ok: false, error })
120
121export default { Admission }
lib/registrations.js +10 -3
……@@ -31,6 +31,8 @@
3131// proving the caller controls it is the caller's job and must happen before claim() is reached.
3232
3333import fs from 'bare-fs'
34import crypto from 'bare-crypto'
35import b4a from 'b4a'
3436import { dirname } from 'bare-path'
3537
3638const HEX64 = /^[0-9a-f]{64}$/i
……@@ -127,10 +129,15 @@ class BaseRegistrations {
127129 }
128130}
129131
132/**
133 * ⛔ A CSPRNG, because on an `invite` bridge this token IS the admission gate.
134 *
135 * This was four draws from `Math.random()`, which is not cryptographically secure and is seeded and
136 * advanced predictably — so the one secret standing between a stranger and a name on somebody's
137 * domain was guessable. bare-crypto was already a dependency of this package.
138 */
130139function randomToken () {
131 let s = ''
132 for (let i = 0; i < 4; i++) s += Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, '0')
133 return s
140 return b4a.toString(crypto.randomBytes(16), 'hex')
134141}
135142
136143export class Registrations extends BaseRegistrations {
lib/router.js +34 -1
……@@ -37,9 +37,17 @@ export async function handleRequest (req, ctx) {
3737 // Off unless the operator turned it on: no registry means 404, never a silent accept.
3838 // GET is discovery — a peer asks whether this bridge is open before trying, rather than guessing
3939 // from a refusal.
40 // ⛔ THE CHALLENGE. `readAttested` has no clock, so it cannot detect replay; freshness has to come
41 // from something this bridge issues and remembers. See lib/admission.js.
42 if (path === '/ap/register/challenge') {
43 if (!ctx.registrations || !ctx.admission) return text(404, 'not found')
44 if (method !== 'GET' && method !== 'POST') return text(405, 'GET or POST only')
45 return json(200, ctx.admission.challenge())
46 }
47
4048 if (path === '/ap/register') {
4149 if (!ctx.registrations) return text(404, 'not found')
42 if (method === 'GET') return json(200, { policy: ctx.registrations.policy })
50 if (method === 'GET') return json(200, { policy: ctx.registrations.policy, proof: !!ctx.admission })
4351 /**
4452 * ⛔ RELEASE — the holder's half. The operator's half goes through the control socket, because
4553 * "any name on this bridge" is not a thing an HTTP caller should be able to assert.
……@@ -51,6 +59,16 @@ export async function handleRequest (req, ctx) {
5159 if (method === 'DELETE') {
5260 let del
5361 try { del = JSON.parse(body || '{}') } catch { return json(400, { error: 'bad json' }) }
62 // ⛔ PROVED, because releasing BURNS the identity. The generation counter deliberately makes a
63 // reused name a different actor, so there is no way back — and this used to compare a bare
64 // `root`, which is public. Anyone who could read a root could destroy the actor behind it.
65 // ⛔ NOT OPTIONAL. A conditional gate is a gate that is absent wherever the condition is, and
66 // "the tests passed" would then mean "the tests ran without it".
67 if (!ctx.admission) return json(503, { error: 'registration is not configured on this bridge' })
68 {
69 const ok = ctx.admission.verifyRelease({ nonce: del.nonce, username: del.username, root: del.root, proof: del.proof })
70 if (!ok.ok) return json(403, { error: ok.error })
71 }
5472 const rel = await ctx.registrations.release(del.username, { root: del.root })
5573 if (!rel.ok) return json(rel.error === 'not registered' ? 404 : 403, { error: rel.error })
5674 if (ctx.onReleased) { try { await ctx.onReleased(del.username) } catch {} }
……@@ -76,6 +94,21 @@ export async function handleRequest (req, ctx) {
7694 let req_
7795 try { req_ = JSON.parse(body || '{}') } catch { return json(400, { error: 'bad json' }) }
7896
97 // ⛔ CONTROL, before consent. hyper-social #65 §8: admission needs both and neither substitutes
98 // for the other. The policy half (open/invite) was built; this half was only ever asserted in a
99 // comment, so an identity root — which is PUBLIC — was accepted as proof of holding it.
100 if (!ctx.admission) return json(503, { error: 'registration is not configured on this bridge' })
101 {
102 const ok = ctx.admission.verifyClaim({
103 nonce: req_.nonce,
104 username: req_.username,
105 feedKey: req_.feedKey || null,
106 root: req_.root,
107 proof: req_.proof
108 })
109 if (!ok.ok) return json(403, { error: ok.error })
110 }
111
79112 const r = await ctx.registrations.claim({
80113 username: req_.username,
81114 root: req_.root,
test/admission.test.js +140 -0
……@@ -0,0 +1,140 @@
1// admission.test.js — proving CONTROL, which this bridge has never done.
2//
3// ⛔ THE HOLE THIS CLOSES. `registrations.js` requires a `root` and checks only its SHAPE. Its own
4// header says so — "it does not verify the chain proof: that is favela-identity's readAttested… it
5// belongs at the route where the challenge lives" — and no route had a challenge, so `readAttested`
6// appeared in this package only inside that comment. An identity root is PUBLIC. Presenting one
7// proved nothing, so anyone could claim a name under anyone's identity, and `release()` compared a
8// bare string, so anyone could BURN someone's actor — irreversibly, since the generation counter
9// makes a reused name a different actor on purpose.
10//
11// hyper-social #65 §8's rule is that admission needs CONSENT and CONTROL and neither substitutes for
12// the other. The policy half (open/invite) was built; this is the half that was only ever asserted.
13//
14// ⛔ WHY A CHALLENGE AND NOT JUST A SIGNATURE. `readAttested` is a pure function of the proof's bytes
15// — no disk, no network, NO CLOCK — which is what lets it run inside an autobase apply. A verifier
16// with no clock cannot detect replay, so freshness has to come from something the bridge issues.
17// There is nowhere else to put it.
18//
19// ⛔ AND THE REQUEST IS INSIDE THE SIGNED BYTES. A proof covering only the nonce could be captured
20// and replayed to claim a DIFFERENT name under the signer's root: consent proven, control proven,
21// wrong name registered.
22
23import test from 'brittle'
24import b4a from 'b4a'
25import IdentityKey from 'keet-identity-key'
26import crypto from 'hypercore-crypto'
27import { signAttested } from 'favela-identity/lib/attestation.js'
28import { Admission } from '../lib/admission.js'
29
30const FEED = 'f'.repeat(64)
31
32// A throwaway identity with one device attested directly by its root — the shape a vault mints once.
33async function identity () {
34 const idk = await IdentityKey.from({ mnemonic: IdentityKey.generateMnemonic() })
35 const root = idk.identityKeyPair
36 const kp = crypto.keyPair()
37 const proof = await IdentityKey.bootstrap({ identity: root }, kp.publicKey)
38 return {
39 rootHex: b4a.toString(root.publicKey, 'hex'),
40 cred: { keyPair: kp, proof: b4a.toString(proof, 'base64') }
41 }
42}
43
44test('★★ a claim signed by the root it names is admitted', async (t) => {
45 const a = new Admission()
46 const me = await identity()
47 const { nonce } = a.challenge()
48 const proof = signAttested(a.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)
49
50 const r = a.verifyClaim({ nonce, username: 'alice', feedKey: FEED, root: me.rootHex, proof })
51 t.ok(r.ok, r.error || '')
52})
53
54test('⛔ a proof from a DIFFERENT identity is refused — this is the whole point', async (t) => {
55 const a = new Admission()
56 const me = await identity()
57 const someoneElse = await identity()
58 const { nonce } = a.challenge()
59 // Signed by me, but claiming to be them. Their root is public, so this is the trivial attack.
60 const proof = signAttested(a.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)
61
62 const r = a.verifyClaim({ nonce, username: 'alice', feedKey: FEED, root: someoneElse.rootHex, proof })
63 t.absent(r.ok, 'refused')
64 t.is(r.error, 'proof does not attest that identity')
65})
66
67test('⛔ a captured proof cannot be replayed for a different name or feed', async (t) => {
68 const me = await identity()
69
70 let a = new Admission()
71 let { nonce } = a.challenge()
72 const proof = signAttested(a.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)
73 t.absent(a.verifyClaim({ nonce, username: 'bob', feedKey: FEED, root: me.rootHex, proof }).ok,
74 'a different name is refused')
75
76 a = new Admission()
77 ;({ nonce } = a.challenge())
78 const p2 = signAttested(a.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)
79 t.absent(a.verifyClaim({ nonce, username: 'alice', feedKey: 'e'.repeat(64), root: me.rootHex, proof: p2 }).ok,
80 'a different feed is refused')
81})
82
83test('⛔ a nonce is single-use, so a proof cannot be replayed at all', async (t) => {
84 const a = new Admission()
85 const me = await identity()
86 const { nonce } = a.challenge()
87 const proof = signAttested(a.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)
88
89 t.ok(a.verifyClaim({ nonce, username: 'alice', feedKey: FEED, root: me.rootHex, proof }).ok, 'first use')
90 const again = a.verifyClaim({ nonce, username: 'alice', feedKey: FEED, root: me.rootHex, proof })
91 t.absent(again.ok, 'second use refused')
92 t.is(again.error, 'unknown or expired challenge')
93})
94
95test('⛔ an expired challenge is refused', async (t) => {
96 let now = 1000
97 const a = new Admission({ ttlMs: 100, now: () => now })
98 const me = await identity()
99 const { nonce } = a.challenge()
100 const proof = signAttested(a.claimBytes({ nonce, username: 'alice', feedKey: FEED }), me.cred)
101 now += 101
102 t.is(a.verifyClaim({ nonce, username: 'alice', feedKey: FEED, root: me.rootHex, proof }).error,
103 'unknown or expired challenge')
104})
105
106test('⛔ releasing needs the same proof — a burned actor cannot be undone', async (t) => {
107 const a = new Admission()
108 const me = await identity()
109 const other = await identity()
110
111 let { nonce } = a.challenge()
112 const good = signAttested(a.releaseBytes({ nonce, username: 'alice' }), me.cred)
113 t.ok(a.verifyRelease({ nonce, username: 'alice', root: me.rootHex, proof: good }).ok, 'the holder may')
114
115 ;({ nonce } = a.challenge())
116 const bad = signAttested(a.releaseBytes({ nonce, username: 'alice' }), other.cred)
117 t.absent(a.verifyRelease({ nonce, username: 'alice', root: me.rootHex, proof: bad }).ok,
118 'somebody else may NOT — knowing a public root used to be enough')
119})
120
121test('⚠ a malformed proof is refused rather than throwing — this runs on a request path', async (t) => {
122 const a = new Admission()
123 const me = await identity()
124 for (const bad of ['', 'not-base64!!', b4a.toString(b4a.alloc(8), 'base64'), null]) {
125 const { nonce } = a.challenge()
126 const r = a.verifyClaim({ nonce, username: 'alice', feedKey: FEED, root: me.rootHex, proof: bad })
127 t.absent(r.ok, 'refused: ' + JSON.stringify(bad))
128 }
129})
130
131test('★ nonces are unguessable and distinct', (t) => {
132 const a = new Admission()
133 const seen = new Set()
134 for (let i = 0; i < 200; i++) {
135 const { nonce } = a.challenge()
136 t.ok(/^[0-9a-f]{64}$/.test(nonce), 'looks like 32 random bytes')
137 seen.add(nonce)
138 }
139 t.is(seen.size, 200, 'no collisions')
140})
test/register-route.test.js +118 -15
……@@ -5,26 +5,55 @@
55// operator sent it.
66
77import test from 'brittle'
8import b4a from 'b4a'
9import IdentityKey from 'keet-identity-key'
10import crypto from 'hypercore-crypto'
11import { signAttested } from 'favela-identity/lib/attestation.js'
812import { handleRequest as route } from '../lib/router.js'
913import { MemoryRegistrations } from '../lib/registrations.js'
14import { Admission } from '../lib/admission.js'
15
16// ⛔ A REAL IDENTITY, not a placeholder root. These tests used `'a'.repeat(64)` and passed, which is
17// exactly the defect: an identity root is PUBLIC, so accepting one as proof of holding it let anyone
18// claim a name under anyone's identity. The ceremony below is what a peer actually performs.
19let ME = null
20async function me () {
21 if (ME) return ME
22 const idk = await IdentityKey.from({ mnemonic: IdentityKey.generateMnemonic() })
23 const root = idk.identityKeyPair
24 const kp = crypto.keyPair()
25 const proof = await IdentityKey.bootstrap({ identity: root }, kp.publicKey)
26 ME = { rootHex: b4a.toString(root.publicKey, 'hex'), cred: { keyPair: kp, proof: b4a.toString(proof, 'base64') } }
27 return ME
28}
1029
11const ROOT = 'a'.repeat(64)
1230const ctxFor = (policy) => ({
1331 config: { host: 'ap.7rnx.net', username: 'primary' },
1432 registrations: new MemoryRegistrations({ policy }),
33 admission: new Admission(),
1534 log: () => {}
1635})
1736const post = (body) => ({ method: 'POST', path: '/ap/register', headers: {}, query: {}, body: JSON.stringify(body) })
1837
38/** The claim a peer sends: ask for a challenge, sign the exact request, send both. */
39async function claimBody (ctx, { username, feedKey = 'f'.repeat(64), invite = null }) {
40 const who = await me()
41 const { nonce } = ctx.admission.challenge()
42 const proof = signAttested(ctx.admission.claimBytes({ nonce, username, feedKey }), who.cred)
43 const body = { username, root: who.rootHex, feedKey, nonce, proof }
44 if (invite) body.invite = invite
45 return body
46}
47
1948test('★★ an open bridge grants a name on request — nobody in the loop', async (t) => {
2049 const ctx = ctxFor('open')
21 const res = await route(post({ username: 'ashatron', root: ROOT, feedKey: 'f'.repeat(64) }), ctx)
50 const res = await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx)
2251
2352 t.is(res.status, 200)
2453 const doc = JSON.parse(res.body)
2554 t.is(doc.username, 'ashatron')
2655 t.is(doc.actor, 'https://ap.7rnx.net/ap/users/ashatron/actor', 'and it says where the actor now lives')
27 t.is((await ctx.registrations.get('ashatron')).root, ROOT)
56 t.is((await ctx.registrations.get('ashatron')).root, (await me()).rootHex)
2857})
2958
3059test('★ a peer can ask whether a bridge is open before trying', async (t) => {
……@@ -36,7 +65,7 @@ test('★ a peer can ask whether a bridge is open before trying', async (t) => {
3665
3766test('⛔ a closed bridge refuses, and says what is needed', async (t) => {
3867 const ctx = ctxFor('invite')
39 const res = await route(post({ username: 'ashatron', root: ROOT }), ctx)
68 const res = await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx)
4069
4170 t.is(res.status, 403)
4271 t.is(JSON.parse(res.body).error, 'invite required')
……@@ -46,39 +75,48 @@ test('★ with the invite the operator sent, the same request works', async (t)
4675 const ctx = ctxFor('invite')
4776 const invite = await ctx.registrations.invite()
4877
49 const res = await route(post({ username: 'ashatron', root: ROOT, invite }), ctx)
78 const res = await route(post(await claimBody(ctx, { username: 'ashatron', invite })), ctx)
5079 t.is(res.status, 200)
5180})
5281
5382test('⛔ a taken name is refused, not silently reassigned', async (t) => {
5483 const ctx = ctxFor('open')
55 await route(post({ username: 'ashatron', root: ROOT }), ctx)
56 const res = await route(post({ username: 'ashatron', root: 'b'.repeat(64) }), ctx)
84 await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx)
85 const res = await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx)
5786
5887 t.is(res.status, 409)
5988 t.is(JSON.parse(res.body).error, 'taken')
6089})
6190
6291test('⛔ a bridge with no registry does not silently accept', async (t) => {
63 const res = await route(post({ username: 'x', root: ROOT }), { config: { host: 'h' }, log: () => {} })
92 const res = await route(post({ username: 'x', root: 'a'.repeat(64) }), { config: { host: 'h' }, log: () => {} })
6493 t.is(res.status, 404, 'registration is off unless the operator turned it on')
6594})
6695
6796test('⛔ garbage in is refused, not guessed at', async (t) => {
6897 const ctx = ctxFor('open')
6998 t.is((await route({ ...post({}), body: 'not json' }, ctx)).status, 400)
70 t.is((await route(post({ username: 'x' }), ctx)).status, 400, 'a name with no identity behind it')
71 t.is((await route({ ...post({ username: 'x', root: ROOT }), method: 'PUT' }, ctx)).status, 405)
99 // ⚠ 403, not 400: the proof gate runs BEFORE the registry examines the request's shape. An
100 // unproven caller is turned away without its request being inspected at all, which is the correct
101 // order — the bridge owes a stranger no diagnosis of a request it will not consider.
102 t.is((await route(post({ username: 'x' }), ctx)).status, 403, 'a name with no identity behind it')
103 t.is((await route({ ...post({ username: 'x', root: 'a'.repeat(64) }), method: 'PUT' }, ctx)).status, 405)
72104})
73105
74106test('★ the holder can release their name over the same endpoint', async (t) => {
75107 const ctx = ctxFor('open')
76 await route(post({ username: 'ashatron', root: ROOT }), ctx)
108 await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx)
109 const who = await me()
77110
78111 const del = (body) => route({ method: 'DELETE', path: '/ap/register', headers: {}, query: {}, body: JSON.stringify(body) }, ctx)
79112
113 // ⚠ A stranger now fails on the PROOF, not on a string comparison — which is the difference between
114 // "you are not the holder" and "you cannot demonstrate that you are".
80115 t.is((await del({ username: 'ashatron', root: 'b'.repeat(64) })).status, 403, 'a stranger cannot')
81 t.is((await del({ username: 'ashatron', root: ROOT })).status, 200, 'the holder can')
116
117 const { nonce } = ctx.admission.challenge()
118 const proof = signAttested(ctx.admission.releaseBytes({ nonce, username: 'ashatron' }), who.cred)
119 t.is((await del({ username: 'ashatron', root: who.rootHex, nonce, proof })).status, 200, 'the holder can')
82120 t.absent(await ctx.registrations.get('ashatron'))
83121})
84122
……@@ -86,9 +124,74 @@ test('★ the holder can release their name over the same endpoint', async (t) =
86124// federated reference to the last holder cannot resolve to the next one.
87125test('⛔ a re-claimed name is a new generation', async (t) => {
88126 const ctx = ctxFor('open')
89 await route(post({ username: 'ashatron', root: ROOT }), ctx)
90 await route({ method: 'DELETE', path: '/ap/register', headers: {}, query: {}, body: JSON.stringify({ username: 'ashatron', root: ROOT }) }, ctx)
127 const who = await me()
128 await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx)
91129
92 const again = await route(post({ username: 'ashatron', root: 'c'.repeat(64) }), ctx)
130 const { nonce } = ctx.admission.challenge()
131 const proof = signAttested(ctx.admission.releaseBytes({ nonce, username: 'ashatron' }), who.cred)
132 await route({ method: 'DELETE', path: '/ap/register', headers: {}, query: {}, body: JSON.stringify({ username: 'ashatron', root: who.rootHex, nonce, proof }) }, ctx)
133
134 const again = await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx)
93135 t.is(JSON.parse(again.body).generation, 2)
94136})
137
138// ⛔ THE REGRESSION THIS FILE EXISTED WITHOUT. Every test above used to pass while sending no proof
139// at all, because there was none to send: an identity root is public and the route accepted it as
140// evidence of holding it. These pin the refusals, so the gate cannot quietly stop applying.
141test('⛔⛔ a claim with no proof is refused — a public root is not evidence', async (t) => {
142 const ctx = ctxFor('open')
143 const who = await me()
144 const res = await route(post({ username: 'ashatron', root: who.rootHex, feedKey: 'f'.repeat(64) }), ctx)
145
146 t.is(res.status, 403)
147 t.is(JSON.parse(res.body).error, 'unknown or expired challenge')
148 t.is(await ctx.registrations.get('ashatron'), null, 'and nothing was registered')
149})
150
151test('⛔⛔ a claim proved by a DIFFERENT identity cannot take your name', async (t) => {
152 const ctx = ctxFor('open')
153 const victim = await me()
154
155 // An attacker with their own valid identity, naming the victim's public root.
156 const idk = await IdentityKey.from({ mnemonic: IdentityKey.generateMnemonic() })
157 const kp = crypto.keyPair()
158 const attacker = { keyPair: kp, proof: b4a.toString(await IdentityKey.bootstrap({ identity: idk.identityKeyPair }, kp.publicKey), 'base64') }
159
160 const { nonce } = ctx.admission.challenge()
161 const proof = signAttested(ctx.admission.claimBytes({ nonce, username: 'ashatron', feedKey: 'f'.repeat(64) }), attacker)
162 const res = await route(post({ username: 'ashatron', root: victim.rootHex, feedKey: 'f'.repeat(64), nonce, proof }), ctx)
163
164 t.is(res.status, 403)
165 t.is(JSON.parse(res.body).error, 'proof does not attest that identity')
166})
167
168// ⛔ RELEASE BURNS THE ACTOR. The generation counter makes a reused name a different actor on
169// purpose, so there is no way back — and this compared a bare root until now.
170test('⛔⛔ a release with no proof cannot burn somebody\'s actor', async (t) => {
171 const ctx = ctxFor('open')
172 await route(post(await claimBody(ctx, { username: 'ashatron' })), ctx)
173 const who = await me()
174
175 const res = await route({
176 method: 'DELETE', path: '/ap/register', headers: {}, query: {},
177 body: JSON.stringify({ username: 'ashatron', root: who.rootHex })
178 }, ctx)
179
180 t.is(res.status, 403)
181 t.ok(await ctx.registrations.get('ashatron'), 'the name is still held')
182})
183
184test('★ a peer can fetch a challenge, and each one is single-use', async (t) => {
185 const ctx = ctxFor('open')
186 const res = await route({ method: 'GET', path: '/ap/register/challenge', headers: {}, query: {} }, ctx)
187 t.is(res.status, 200)
188 const { nonce, expiresAt } = JSON.parse(res.body)
189 t.ok(/^[0-9a-f]{64}$/.test(nonce), 'a nonce to sign')
190 t.ok(expiresAt > Date.now(), 'with a deadline')
191
192 const who = await me()
193 const proof = signAttested(ctx.admission.claimBytes({ nonce, username: 'ashatron', feedKey: 'f'.repeat(64) }), who.cred)
194 const body = { username: 'ashatron', root: who.rootHex, feedKey: 'f'.repeat(64), nonce, proof }
195 t.is((await route(post(body), ctx)).status, 200, 'the first use works')
196 t.is((await route(post({ ...body, username: 'other' }), ctx)).status, 403, 'and it cannot be used again')
197})