OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer commits / 145cfc0

make first-claim TOFU safe on a surface that is not loopback
⛔ THE PREMISE THIS FILE RELIED ON. browsers.js says it plainly: the safety of first-claim TOFU "rests ENTIRELY on the surface binding localhost… exposing it before claiming it hands it to whoever is on the network. It is a first-run convenience, not an authorisation model." An operator page on a bridge has to be reachable from the network, so "the first browser to prove a key" becomes "the first stranger who finds the port". A bootstrap token the CALLER prints to its own service log is a secret only the operator can read. Requiring it for the FIRST claim binds that claim to them without inventing an authorisation model, and it closes permanently on success exactly as before — holding the token is not a standing key to the instance. ⛔ AND THE RACE IS REAL, MEASURED. The same header admitted `claim` reads `unclaimed()` and then writes: "two browsers arriving together can both read 'nobody registered'". Ran against the previous lib with two claimants started together: BOTH succeeded, `lost.length` was 0. On loopback that gap is one round trip; on a public port it is something an attacker drives on purpose. Claims now serialise on a latch, so the check and the register are atomic within the one process there is. The token compare is timing-safe. A plain `===` on a secret checked by anyone who can reach the port is a byte-at-a-time oracle. ⚠ NO TOKEN MEANS NOT REQUIRED, which is the loopback default and why all 900 existing assertions pass untouched — favela-peer's own surface binds 127.0.0.1 and claims through dispatch before a listener exists. Against the previous lib the new file scores 2/5: the two gate tests and the race fail, while "claims once" and "no token configured" pass on both, which is the half that must not change. 912/912 with the fix. ⚠ The race test guards `lost[0]`. Unguarded it threw when both claims won — taking the whole file down instead of failing one assertion, so the defect it exists to catch reported nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
145cfc0d3703867d7e2de10605631f5f0ff54537
by plankatron <fleeky@gmail.com> · 2026-09-11T23:41:53.000Z

Changed files (2) +181 -3

lib/browsers.js +57 -3
……@@ -55,6 +55,20 @@
5555import { IdentityRegistry } from 'hyper-identity2'
5656import { an, Challenges } from 'anproto'
5757
58/**
59 * Compare two secrets without leaking their prefix through timing.
60 *
61 * ⚠ A plain `===` on a token checked by anyone who can reach the port is a byte-at-a-time oracle.
62 * Length is compared first and deliberately leaks only the length, which a token of fixed size does
63 * not hide anyway.
64 */
65function timingEqual (a, b) {
66 if (a.length !== b.length) return false
67 let diff = 0
68 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
69 return diff === 0
70}
71
5872// The feed owner's user record. One identity at a time, so one user — every browser grant links to
5973// this one, and revoking a browser never touches the account.
6074const OWNER = 'owner'
……@@ -69,6 +83,25 @@ export class Browsers {
6983 // Keyed by the challenge string itself, single-use, TTL-swept. No ids to correlate.
7084 this.challenges = new Challenges(opts.challenges || {})
7185 this._owner = null
86
87 // ⛔ THE BOOTSTRAP TOKEN — what makes TOFU safe on a surface that is NOT loopback.
88 //
89 // Plain TOFU is a first-run convenience whose safety rests entirely on the surface binding
90 // localhost (see the header): whoever reaches the page first owns the instance. A bridge's
91 // operator page has to be reachable from the network, so "first to arrive" would mean the first
92 // stranger who finds the port. A token the caller prints to its own log at startup is a secret
93 // only the operator can read, and requiring it for the FIRST claim binds that claim to them
94 // without inventing an authorisation model.
95 //
96 // ⚠ null means NOT REQUIRED, which is the loopback case and stays the default — favela-peer's
97 // surface is bound to 127.0.0.1 and claims through dispatch before a listener exists.
98 this._bootstrap = typeof opts.bootstrap === 'string' && opts.bootstrap ? opts.bootstrap : null
99
100 // ⛔ CLAIMS ARE SERIALISED. `claim` reads `unclaimed()` and then writes, and the header admits
101 // the gap: "two browsers arriving together can both read 'nobody registered'". On loopback that
102 // is one round trip; on a public port it is a race an attacker can drive deliberately. One latch
103 // makes the check-and-register atomic within this process, which is the only process there is.
104 this._claiming = Promise.resolve()
72105 }
73106
74107 async ready () {
……@@ -160,11 +193,32 @@ export class Browsers {
160193 * ⛔ Refused the moment ANY browser is registered, revoked ones included. Revocation must not
161194 * reopen the door, or "retire that browser" would silently mean "let the next stranger in".
162195 */
163 async claim ({ publicKey, signature, challenge }) {
196 async claim ({ publicKey, signature, challenge, bootstrap = null }) {
164197 await this._proveKey({ publicKey, signature, challenge })
165 if (!(await this.unclaimed())) {
166 throw new Error('this instance is already claimed — ask its owner for an invite')
198
199 // ⛔ BEFORE THE LATCH AND BEFORE ANY WRITE. A wrong token must cost nothing and change nothing.
200 if (this._bootstrap && !timingEqual(String(bootstrap || ''), this._bootstrap)) {
201 throw new Error('this instance needs its bootstrap token to be claimed — it is printed in the ' +
202 'service log the first time it starts, and only the operator can read that')
203 }
204
205 // ⛔ SERIALISED — see the constructor. Without this the check below and the write under it are a
206 // read-then-write that two claimants can interleave.
207 const previous = this._claiming
208 let done
209 this._claiming = new Promise((resolve) => { done = resolve })
210 try {
211 await previous
212 if (!(await this.unclaimed())) {
213 throw new Error('this instance is already claimed — ask its owner for an invite')
214 }
215 return await this._register(publicKey)
216 } finally {
217 done()
167218 }
219 }
220
221 async _register (publicKey) {
168222 await this.registry.registerIdentity({
169223 identityId: publicKey, method: 'web-identity', publicKey, credentials: { publicKey }
170224 })
test/browsers-bootstrap.test.js +124 -0
……@@ -0,0 +1,124 @@
1// Making TOFU safe on a surface that is NOT loopback.
2//
3// ⛔ THE PREMISE THAT BREAKS. browsers.js says it plainly: the safety of first-claim TOFU "rests
4// ENTIRELY on the surface binding localhost… exposing it before claiming it hands it to whoever is on
5// the network. It is a first-run convenience, not an authorisation model." A bridge's operator page has
6// to be reachable from the network, so "the first browser to prove a key" becomes "the first stranger
7// who finds the port".
8//
9// ⛔ AND THE RACE STOPS BEING THEORETICAL. The same header admits `claim` reads `unclaimed()` and then
10// writes: "two browsers arriving together can both read 'nobody registered'". On loopback that gap is
11// one round trip. On a public port it is something an attacker can drive on purpose.
12//
13// A bootstrap token the CALLER prints to its own service log is a secret only the operator can read,
14// so requiring it for the FIRST claim binds that claim to them without inventing an authorisation
15// model. A latch makes the check-and-register atomic.
16//
17// ⚠ No token configured means NOT REQUIRED — the loopback default, unchanged, which is why every
18// existing browsers test still passes untouched.
19
20import test from 'brittle'
21import Corestore from 'corestore'
22import fs from '#node:fs'
23import os from '#node:os'
24import { join } from '#node:path'
25import { an } from 'anproto'
26import { Browsers } from '../lib/browsers.js'
27
28const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'favela-bootstrap-'))
29const TOKEN = 'b7f4c1a9e2d05638b7f4c1a9e2d05638'
30
31async function open (opts) {
32 const b = new Browsers(new Corestore(tmp()), opts)
33 await b.ready()
34 return b
35}
36
37async function browser () {
38 const keypair = await an.gen()
39 return {
40 publicKey: an.publicKey(keypair),
41 async answer (b) {
42 const { challenge } = b.challenge()
43 return { challenge, signature: await an.sign(await an.hash(challenge), keypair) }
44 }
45 }
46}
47
48test('★★★ with a bootstrap token set, a proved key alone cannot claim', async (t) => {
49 const b = await open({ bootstrap: TOKEN })
50 const who = await browser()
51
52 // ⚠ The answer is taken BEFORE the arrow: `await` inside a non-async arrow is a parse error, not a
53 // runtime one, so it takes the whole file down rather than the test.
54 const proof = await who.answer(b)
55 await t.exception(
56 () => b.claim({ publicKey: who.publicKey, ...proof }),
57 /bootstrap token/,
58 'proving the key is not enough on a surface strangers can reach')
59
60 t.ok(await b.unclaimed(), 'and the refusal changed nothing — the instance is still unclaimed')
61})
62
63test('★★★ a wrong token is refused and costs nothing', async (t) => {
64 const b = await open({ bootstrap: TOKEN })
65 const who = await browser()
66
67 const proof = await who.answer(b)
68 await t.exception(
69 () => b.claim({ publicKey: who.publicKey, ...proof, bootstrap: 'b7f4c1a9e2d05638b7f4c1a9e2d05639' }),
70 /bootstrap token/,
71 'one byte different is still wrong')
72 t.ok(await b.unclaimed(), 'nothing was written')
73})
74
75test('★★★ the right token claims it, once', async (t) => {
76 const b = await open({ bootstrap: TOKEN })
77 const first = await browser()
78
79 const firstProof = await first.answer(b)
80 const out = await b.claim({ publicKey: first.publicKey, ...firstProof, bootstrap: TOKEN })
81 t.ok(out.claimed)
82 t.absent(await b.unclaimed(), 'the instance is claimed')
83
84 // ⛔ It closes PERMANENTLY. Holding the token does not reopen it — the token gates the first claim,
85 // it is not a standing key to the instance.
86 const second = await browser()
87 const secondProof = await second.answer(b)
88 await t.exception(
89 () => b.claim({ publicKey: second.publicKey, ...secondProof, bootstrap: TOKEN }),
90 /already claimed/,
91 'a second claimant with the same token is still refused')
92})
93
94test('★★★ two claimants arriving together — exactly one wins', async (t) => {
95 // The race the header admits. Both prove their key, both present the token, both arrive before
96 // either has written. Without the latch both read "unclaimed" and both register.
97 const b = await open({ bootstrap: TOKEN })
98 const a1 = await browser()
99 const a2 = await browser()
100
101 const proofs = [
102 { publicKey: a1.publicKey, ...(await a1.answer(b)), bootstrap: TOKEN },
103 { publicKey: a2.publicKey, ...(await a2.answer(b)), bootstrap: TOKEN }
104 ]
105 const results = await Promise.allSettled(proofs.map((p) => b.claim(p)))
106
107 const won = results.filter((r) => r.status === 'fulfilled')
108 const lost = results.filter((r) => r.status === 'rejected')
109 t.is(won.length, 1, 'exactly one claim succeeded')
110 t.is(lost.length, 1, 'and the other was told the instance was already claimed')
111 // ⚠ Guarded. Without the latch BOTH claims succeed and `lost` is empty — measured — so an
112 // unguarded `lost[0].reason` throws and takes the whole FILE down instead of failing this one
113 // assertion. A test that crashes on the defect it exists to catch reports nothing.
114 const why = lost.length ? String((lost[0].reason && lost[0].reason.message) || lost[0].reason) : '(nobody lost — both claims won)'
115 t.ok(/already claimed/.test(why), why)
116})
117
118test('★★ with no token configured nothing changes — the loopback default', async (t) => {
119 const b = await open()
120 const who = await browser()
121 const proof = await who.answer(b)
122 const out = await b.claim({ publicKey: who.publicKey, ...proof })
123 t.ok(out.claimed, 'first-claim TOFU still works where the surface is bound to localhost')
124})