OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android commits / 6eea52f

did-mint could not finish, and boot announced half of what it holds
Two defects on the handset, both measured rather than inferred. did-mint derived every key, built the genesis, and threw ENOENT on its last line: `mintIdentity` defaults its config to os.homedir() + '/.config/...' and `writeConfigFile` is a bare writeFileSync with no mkdirSync, while this file's own header says "There is no shell, no $HOME and no env". So a mint produced a recovery root and lost it, with no way to ask for it again. Reproduced with HOME=/proc/self, the same instrument that found the OTA homedir chain in #69 §1. It now writes under the node's storage — the fix favela-peer already made for its own vault. Nothing sealed the root either. Ported favela-android's pattern: refuse below an 8-character passphrase BEFORE minting, then seal to a vault bound to the DID. Seal failure is reported, not thrown — the identity exists and the words are about to be shown, so destroying it over a failed backup is the worse outcome. boot() joined node.peer.feed and node._feeds by hand, which is precisely the code favela-peer's index.js replaced: "Three things have needed re-joining by hand here and each was forgotten in turn, producing a log that replicates for nobody." Two were forgotten here — the namespace and the rooms. It hid because it is the boot path only: _openNames announces the namespace when a transport exists, so a namespace joined while running replicates and then silently stops after the next restart. The composition (inProcessClient, serveSurface, listenPreferring, signIn) moves to favela-host so a desktop can hold it too, and is re-exported here because the off-device tests pin it at this path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6eea52f1332cba396d55ab507c30645ffe6bbbe9
by plankatron <fleeky@gmail.com> · 2026-09-11T15:16:58.000Z

Changed files (4) +198 -183

lib/node.js +23 -182
……@@ -30,14 +30,9 @@ import 'hyper-proto-peer/lib/bare-globals.js'
3030import fs from 'bare-fs'
3131import process from 'bare-process'
3232import { join } from 'bare-path'
33import http from 'bare-http1'
3433import Hyperswarm from 'hyperswarm'
3534import { FavelaNode } from 'favela-peer/lib/node.js'
3635import { SwarmTransport } from 'favela-peer/lib/transport.js'
37import { dispatch } from 'favela-peer/lib/rpc.js'
38import { route } from 'favela-surface/lib/router.js'
39import { Sessions } from 'favela-surface/lib/session.js'
40import { an } from 'anproto'
4136import { createUtf8Decoder } from './host/utf8.js'
4237// Stamped at build time by scripts/gen-version.mjs — the bundle carries no readable package.json.
4338import { VERSION, BUILT_AT } from './version.js'
……@@ -64,183 +59,22 @@ let otaHandle = null
6459let refreshUpdateState = () => null
6560let lastOtaNote = null
6661
67/**
68 * The in-process daemon client — what `route` is handed instead of a socket client.
69 *
70 * ⛔ The same `call(cmd, args) -> Promise` shape favela-surface's own `Client` has, so that file
71 * never learns which it got. This is not an emulation of the socket: `dispatch` is the layer the
72 * socket server itself calls.
73 */
74export function inProcessClient (node) {
75 return {
76 call: async (cmd, args = {}) => {
77 // ⛔ THE WORKLET OWNS THE COMPOSITION, and it has to: favela-peer's own boundary test forbids
78 // the daemon importing anything `atproto-`, and hyper-proto-peer's mint reaches
79 // atproto-identity. So the atproto ops cannot live in `dispatch` — but they can live HERE,
80 // where the two halves are already in one process, and the surface calls them the same way it
81 // calls everything else. Neither package learns about the other.
82 if (ATPROTO_OPS[cmd]) return ATPROTO_OPS[cmd](node, args)
83 return dispatch(node, cmd, args)
84 }
85 }
86}
8762
88// ⛔ MINTING IS NOT SUBMITTING. `mintIdentity` never touches the network — submitting the genesis to
89// the PLC directory is "a PERMANENT, PUBLIC, IRREVERSIBLE append to a global log" and is a separate
90// call for that reason. A mint that cannot reach the directory still yields a working P2P identity,
91// and the op can be re-derived from the mnemonic and submitted later.
63// ── the composition, which now lives in favela-host ─────────────────────────────────────────────
9264//
93// ⛔ AND IT IS P2P-ONLY: no handle, no PDS. hyper-proto-peer's mint records why — "creating an
94// identity and joining someone's infrastructure are different decisions, and requiring both at once
95// is what made minting undecidable". The handle and service endpoint arrive later as a PLC update.
96const ATPROTO_OPS = {
97 'did-mint': async (node, a) => {
98 const { mintIdentity } = await import('hyper-proto-peer/lib/mint.js')
99 const r = await mintIdentity({ rootMnemonic: a.mnemonic || null, onStep: () => {} })
100 // ⚠ The root mnemonic comes back and is NOT stored here. hpp's mint is explicit: minting on the
101 // device is the one moment the recovery root exists there, so it is RETURNED for the caller to
102 // show once and nothing persists it in the config. Its own test asserts that.
103 return { did: r.did, mnemonic: r.rootMnemonic, device: r.devicePublicKey }
104 },
105 'did-submit': async (node, a) => {
106 const { submitMintedGenesis } = await import('hyper-proto-peer/lib/mint.js')
107 return submitMintedGenesis({ did: a.did })
108 }
109}
110
111/**
112 * The loopback surface. Split out from `boot` so it can be exercised off-device: given any node,
113 * it stands up the real routes over real HTTP with no bare-kit, no worklet and no Android.
114 *
115 * ⚠ Binds 127.0.0.1 explicitly. bare-http1's `listen(port)` defaults to all interfaces on some
116 * platforms, and a favela node answering on a phone's Wi-Fi address is not what anyone asked for.
117 */
118/**
119 * Bind `port` if we can, otherwise any free one.
120 *
121 * ⛔ A STABLE PORT, BECAUSE THE WEBVIEW HOLDS THE URL. Every restart used to bind a fresh ephemeral
122 * port, so the page the user was looking at pointed at a dead server the moment the node came back —
123 * which is why "restart to apply" looked like it did nothing and the banner appeared frozen. The node
124 * HAD restarted and was serving the new bundle; nobody could see it.
125 *
126 * ⚠ Falls back rather than failing. Another app can hold the remembered port, and refusing to boot over
127 * a port number would be a far worse failure than a changed URL.
128 */
129export function listenPreferring (server, port, host = '127.0.0.1') {
130 return new Promise((resolve, reject) => {
131 const onError = (err) => {
132 if (!port) return reject(err)
133 server.removeListener('error', onError)
134 server.once('error', reject)
135 server.listen(0, host, resolve)
136 }
137 server.once('error', onError)
138 server.listen(port, host, () => { server.removeListener('error', onError); resolve() })
139 })
140}
141
142export async function serveSurface (node, { port = 0, sessions = new Sessions(), appUpdate = null } = {}) {
143 const client = inProcessClient(node)
144 const server = http.createServer((req, res) => {
145 // ⛔ THE EMBEDDER SUPPLIES IT. favela-surface's router reads no environment by design (its own
146 // test asserts that), so the shell state reaches the settings page through ctx or not at all.
147 // On a desktop nothing supplies it and the section correctly does not render.
148 route(req, res, { client, sessions, appUpdate }).catch((err) => {
149 res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
150 res.end(String((err && err.message) || err))
151 })
152 })
153 // ⛔ A STABLE PORT, BECAUSE THE WEBVIEW HOLDS THE URL. Every restart used to bind a fresh ephemeral
154 // port, so the page the user was looking at pointed at a dead server the moment the node came back —
155 // which is why "restart to apply" looked like it did nothing and the banner appeared frozen. The node
156 // HAD restarted and was serving the new bundle; nobody could see it.
157 //
158 // ⚠ Falls back to ephemeral if the remembered port is taken. Another app can hold it, and refusing to
159 // boot over a port number would be a far worse failure than a changed URL.
160 await listenPreferring(server, port)
161 const bound = server.address().port
162 return { server, sessions, client, port: bound, url: 'http://127.0.0.1:' + bound }
163}
164
165// The app's own browser identity, on disk beside the node's storage.
65// ⛔ MOVED, NOT COPIED. `inProcessClient`, `serveSurface`, `listenPreferring` and `signIn` were
66// written here because this was the only place that held both a favela-peer node and favela-surface.
67// A desktop had no such place, so the phone could mint a DID and a desktop could not — the parity
68// gap. They are platform-neutral and now live in `favela-host`, which both this worklet and the
69// desktop entry import. `serveSurface`'s own comment always said so: "On a desktop nothing supplies
70// it and the section correctly does not render."
16671//
167// ⛔ A browser is a CONTROLLER, not a device. It cannot hold a writer core or verify a chain, so its
168// key never enters the attestation chain — favela-peer's `lib/browsers.js` says exactly this, and it
169// is why attesting one would hand a WebView the power to mint devices. The app authenticates; the
170// daemon writes.
171const BROWSER_KEY = 'browser.key'
172// Where the last bound surface port is remembered, so a restart serves the same URL the WebView holds.
173const PORT_FILE = 'surface-port'
174
175/**
176 * Load or mint this app's anproto keypair.
177 *
178 * ⚠ PERSISTED, and it has to be. A key regenerated each boot would claim the instance on first run
179 * and then be a stranger on the second — permanently locked out, because TOFU closes on the first
180 * claim and never reopens, not even for the claimant. 0600, and it is the one secret this app keeps
181 * that is not the node's own.
182 */
183async function browserKeypair (storage) {
184 const p = join(storage, BROWSER_KEY)
185 try {
186 const kp = fs.readFileSync(p, 'utf8').trim()
187 if (kp.length === 132) return kp
188 } catch { /* first run */ }
189 const kp = await an.gen()
190 fs.writeFileSync(p, kp + '\n', { mode: 0o600 })
191 return kp
192}
193
194/**
195 * Prove this app's key to the daemon, claiming the instance on first run.
196 *
197 * ⛔ OVER THE IN-PROCESS CLIENT, never over HTTP — and that is a security property, not tidiness.
198 * TOFU accepts the first key to prove itself, and favela-peer's own note says the safety of that
199 * "rests ENTIRELY on the surface binding localhost". On Android every app shares loopback, so a
200 * claim made over the listener could in principle be raced by anything else on the device that
201 * found the port. Claiming through `dispatch` happens before the listener exists and cannot be
202 * reached from outside this process at all.
203 *
204 * The order matters and is not arbitrary:
205 * 1. `browser-status` — already known? Then just authenticate. Re-claiming is not possible.
206 * 2. revoked? Say so plainly. A revoked identity stays registered, so this is terminal without
207 * a human, and pretending otherwise would loop.
208 * 3. unclaimed? Claim it. This is the fresh-install case.
209 * 4. otherwise someone else holds it, and an invite is the only way in.
210 *
211 * ⚠ `an.sign` signs a HASH, not the challenge — `_proveKey` compares `an.parse(opened).hash`
212 * against `an.hash(challenge)`. Signing the raw string produces a valid signature over the wrong
213 * thing, which verifies and then fails the hash check.
214 */
215export async function signIn (node, storage) {
216 const keypair = await browserKeypair(storage)
217 const publicKey = an.publicKey(keypair)
218 const call = (cmd, args = {}) => dispatch(node, cmd, args)
219
220 const prove = async () => {
221 const { challenge } = await call('browser-challenge')
222 const signature = await an.sign(await an.hash(challenge), keypair)
223 return { publicKey, signature, challenge }
224 }
225
226 const st = await call('browser-status', { publicKey })
227 if (st && st.revoked) {
228 throw new Error('this app\'s browser identity was revoked — a revoked identity stays registered, ' +
229 'so it needs an invite from the CLI or another signed-in browser')
230 }
231 if (st && st.known) {
232 await call('browser-auth', await prove())
233 return { publicKey, claimed: false }
234 }
235
236 const { unclaimed } = await call('browser-unclaimed')
237 if (!unclaimed) {
238 throw new Error('another browser already claimed this node — TOFU closes on the first claim and ' +
239 'never reopens, so this app needs a one-time invite')
240 }
241 await call('browser-claim', await prove())
242 return { publicKey, claimed: true }
243}
72// ⚠ Re-exported because the off-device tests import them from this path, and moving an
73// implementation is not a reason to rewrite the tests that pin it.
74// ⚠ Imported AND re-exported. `export ... from` alone re-exports without creating local bindings, so
75// `boot()` below — which calls serveSurface and signIn — failed with "serveSurface is not defined".
76import { inProcessClient, serveSurface, listenPreferring, signIn, PORT_FILE } from 'favela-host'
77export { inProcessClient, serveSurface, listenPreferring, signIn, PORT_FILE }
24478
24579// ── boot ──────────────────────────────────────────────────────────────────────────────────────
24680
……@@ -261,9 +95,16 @@ export async function boot (storage) {
26195 stage('node ready', { feed: node.key.toString('hex') })
26296
26397 node.transport = new SwarmTransport(swarm, { overlay: node.overlay })
264 await node.transport.join(node.peer.feed)
265 for (const [, feed] of node._feeds) await node.transport.join(feed)
266 stage('swarm joined')
98 // ⛔ ONE call, and NOT a hand-rolled list. This used to join `node.peer.feed` and `node._feeds`
99 // itself, which is precisely the code favela-peer's index.js replaced — "Three things have needed
100 // re-joining by hand here and each was forgotten in turn, producing a log that replicates for
101 // nobody." Two were forgotten here: the NAMESPACE and the ROOMS.
102 //
103 // ⚠ It hid because this is the BOOT path only. `_openNames` announces the namespace itself when a
104 // transport already exists, so a namespace joined while the node runs replicates — and silently
105 // stops after the next restart, because nothing here joined it again.
106 const announced = await node.announceAll()
107 stage('swarm joined', { announced })
267108
268109 // ⛔ BEFORE the listener. See `signIn`: claiming over a loopback port on Android could be raced
269110 // by any other app on the device that found it, and claiming through `dispatch` cannot.
package.json +2 -1
……@@ -57,7 +57,8 @@
5757 "react-native-bare-kit": "^0.14.5",
5858 "react-native-fs": "^2.20.0",
5959 "react-native-webview": "^14.0.1",
60 "hyper-proto-peer": "file:../hyper-proto-peer"
60 "hyper-proto-peer": "file:../hyper-proto-peer",
61 "favela-host": "file:../favela-host"
6162 },
6263 "devDependencies": {
6364 "@react-native-community/cli": "^20.2.0",
test/announce.test.js +75 -0
……@@ -0,0 +1,75 @@
1// What the worklet announces when it comes up — and why it must not hand-roll the list.
2//
3// ⛔ THE WORKLET HAD DIVERGED. `boot()` joined the peer feed and the followed feeds by hand:
4//
5// await node.transport.join(node.peer.feed)
6// for (const [, feed] of node._feeds) await node.transport.join(feed)
7//
8// which is exactly the code favela-peer's own `index.js` REPLACED, and its comment says why: "Three
9// things have needed re-joining by hand here and each was forgotten in turn, producing a log that
10// replicates for nobody." Two were forgotten here too — the NAMESPACE and the ROOMS.
11//
12// ⚠ And it hid, because it is only the BOOT path. `_openNames` announces the namespace itself when a
13// transport already exists, so a namespace joined while running replicates fine — and then stops
14// replicating after the next restart, when nothing joins it again. `_openNames` states the cost:
15// "Announced, or a shared registry is three private ones."
16//
17// ⚠ THE FIRST TEST READS SOURCE, DELIBERATELY. The defect lives in `boot()`, which builds its own
18// Hyperswarm and SwarmTransport and is therefore not observable from outside without a DHT. A
19// behavioural test of `announceAll` passes with the bug still present — it did, which is how this
20// one came to be written this way. boundary.test.js already takes the same approach for the same
21// reason: pin the thing that actually regresses, where it actually lives.
22
23import test from 'brittle'
24import fs from 'bare-fs'
25import os from 'bare-os'
26import { join } from 'bare-path'
27import Hyperswarm from 'hyperswarm'
28import { FavelaNode } from 'favela-peer/lib/node.js'
29
30const ROOT = new URL('..', import.meta.url).pathname
31const tmp = () => fs.mkdtempSync(join(os.tmpdir(), 'fsa-announce-'))
32
33test('boot announces through announceAll, never a hand-rolled list', (t) => {
34 const src = fs.readFileSync(join(ROOT, 'lib/node.js'), 'utf8')
35
36 t.ok(/node\.announceAll\(\)/.test(src),
37 'boot calls announceAll — the one place that knows the full set')
38 t.absent(/for \(const \[, feed\] of node\._feeds\) await node\.transport\.join/.test(src),
39 'and does not re-enumerate the feeds itself, which is how names and rooms got dropped')
40})
41
42// A transport that records rather than reaching the DHT. `announceAll` asks for nothing else, so this
43// says what the call covers without adding real network timing to a question that is not about it.
44function recorder () {
45 const joined = []
46 return { joined, join: async (base) => { joined.push(base) } }
47}
48
49test('announceAll covers the namespace and the rooms the hand-rolled list missed', async (t) => {
50 const swarm = new Hyperswarm()
51 const node = new FavelaNode(tmp(), { transport: null, swarm })
52 await node.ready()
53 t.teardown(async () => {
54 try { await node.close() } catch {}
55 try { await swarm.destroy() } catch {}
56 })
57
58 // ⚠ A sentinel rather than a real room. `createRoom` needs a linked device credential, and this
59 // test is about which COLLECTIONS the announce walks — not about admission. `announceAll` iterates
60 // `_rooms` and hands each to the transport, so a marker in that map proves the walk reaches it.
61 const room = { marker: 'a joined room' }
62 node._rooms.set('sentinel', room)
63
64 // The order `boot()` uses, and it is load-bearing: ready() builds the overlay and the transport
65 // needs it, so every join inside ready() is a no-op and the announce has to happen after.
66 const rec = recorder()
67 node.transport = rec
68 await node.announceAll()
69
70 t.ok(node.names, 'the node opened a namespace')
71 t.ok(rec.joined.includes(node.peer.feed), 'the peer feed is announced')
72 t.ok(rec.joined.includes(node.names),
73 'and so is the namespace — without this a shared registry is three private ones')
74 t.ok(rec.joined.includes(room), 'and the rooms, which the hand-rolled list also walked past')
75})
test/did-mint.test.js +98 -0
……@@ -0,0 +1,98 @@
1// did-mint has to write where the node's own state lives, and it has to seal what it hands back.
2//
3// lib/node.js's header states the condition: "There is no shell, no $HOME and no env: Android hands
4// the app-private directory in as argv". But `hyper-proto-peer`'s `mintIdentity` defaults its config
5// to `os.homedir() + '/.config/hyper-proto-peer/config.json'`, and `writeConfigFile` is a bare
6// `writeFileSync` with no `mkdirSync` in front of it. So on a handset the mint derived every key,
7// built the genesis, and threw ENOENT on its last line — having produced a recovery root that was
8// then lost, because nothing persisted it.
9//
10// Same failure class as the OTA homedir chain (hyper-social #69 §1), where one message named the
11// PATH and not the caller and it cost four sideload cycles. favela-peer already fixed its own half
12// and wrote down the rule: "THE VAULT LIVES UNDER THE NODE'S STORAGE, not ~/.hyperident. A phone has
13// no home directory... the daemon already knows where its own state goes, so it says."
14//
15// ⚠ $HOME is redirected below rather than trusted. A test that took the desktop default would read —
16// and could write — the developer's REAL hyper-proto-peer config, which on this machine holds a live
17// identity. bare-os honours a runtime change to process.env.HOME (measured), so the redirect is
18// effective for the lazy `import('hyper-proto-peer/lib/mint.js')` inside the handler.
19
20import test from 'brittle'
21import fs from 'bare-fs'
22import os from 'bare-os'
23import process from 'bare-process'
24import { join } from 'bare-path'
25import { inProcessClient } from '../lib/node.js'
26
27const PASS = 'a passphrase long enough'
28
29function scratch (name) {
30 const dir = join(os.tmpdir(), 'favela-did-mint-' + name + '-' + Date.now() + '-' + Math.random().toString(16).slice(2))
31 fs.mkdirSync(dir, { recursive: true })
32 return dir
33}
34
35// A node stub with a redirected $HOME. Minting touches nothing else about a FavelaNode —
36// `provisionDevice` is pure key derivation, with no store and no network.
37function fixture (t, name) {
38 const root = scratch(name)
39 const home = join(root, 'home')
40 const storage = join(root, 'storage')
41 fs.mkdirSync(home, { recursive: true })
42 fs.mkdirSync(storage, { recursive: true })
43 const realHome = process.env.HOME
44 process.env.HOME = home
45 t.teardown(() => { process.env.HOME = realHome })
46 return { home, storage, client: inProcessClient({ storage }) }
47}
48
49test('did-mint writes the identity under the node storage, never under $HOME', async (t) => {
50 const { home, storage, client } = fixture(t, 'paths')
51
52 const r = await client.call('did-mint', { passphrase: PASS })
53
54 t.ok(String(r.did).startsWith('did:plc:'), 'a DID came back')
55 t.absent(
56 fs.existsSync(join(home, '.config', 'hyper-proto-peer', 'config.json')),
57 'nothing was written under $HOME — a handset has none, so anything that lands there is lost'
58 )
59 t.ok(fs.existsSync(join(storage, 'atproto', 'config.json')), 'the config is under the node storage')
60})
61
62// ⛔ The passphrase is the ONLY thing protecting the vault. favela-android's mint says why: the blob
63// is designed to be replicated, so it will land on disks we do not control, and it is safe there only
64// because it is useless without something in a person's head.
65test('did-mint refuses without a passphrase, rather than minting an unprotectable root', async (t) => {
66 const { storage, client } = fixture(t, 'nopass')
67
68 await t.exception(() => client.call('did-mint', {}), /passphrase/)
69 await t.exception(() => client.call('did-mint', { passphrase: 'short' }), /passphrase/)
70
71 t.absent(fs.existsSync(join(storage, 'atproto', 'config.json')),
72 'and it refused BEFORE minting — an identity that cannot be protected must not exist')
73})
74
75test('did-mint seals the recovery root into a vault the passphrase opens', async (t) => {
76 const { storage, client } = fixture(t, 'vault')
77
78 const r = await client.call('did-mint', { passphrase: PASS })
79 t.ok(r.sealed, 'the mint reports that it sealed')
80
81 // Named by the DID's own suffix, because one device may later hold several identities and a single
82 // vault.json would silently mean "the last one minted".
83 const vaultPath = join(storage, 'atproto', 'recovery', String(r.did).split(':').pop() + '.vault.json')
84 t.ok(fs.existsSync(vaultPath), 'the sealed vault is on disk beside the config')
85
86 const { openVault } = await import('atproto-identity/lib/vault.js')
87 const envelope = JSON.parse(fs.readFileSync(vaultPath, 'utf8'))
88 t.is(openVault({ envelope, passphrase: PASS, did: r.did }), r.mnemonic,
89 'and it opens to exactly the phrase the caller was told to write down')
90
91 // The AEAD binds the blob to its identity, so a vault cannot be served in place of another's.
92 t.exception(() => openVault({ envelope, passphrase: 'the wrong one', did: r.did }))
93
94 // hpp's own test asserts this of the config; assert it here too, because this is the composition
95 // that decides where both files go.
96 t.absent(fs.readFileSync(join(storage, 'atproto', 'config.json'), 'utf8').includes(r.mnemonic),
97 'the recovery phrase is not in the config in the clear')
98})