OverviewFilesCommitsIssuesDocsReleasesPeers

favela-android commits / 8d988ca

0.1.11: accept a bridge's invite from the phone, using the vault mint sealed
mint produces a P2P-only identity — no handle, no PDS, nobody's permission required. Being FRONTED by a membrane is what an invite grants, and accepting one is a PLC update adding a handle and a service endpoint. It must be signed by a current rotation key, which for a swarm identity is exactly one: the root recovery key. mint sealed that under a passphrase at creation time for precisely this moment, so accepting an invite costs the user a passphrase rather than 24 re-typed words. ⛔ THE PASSPHRASE IS ALL THAT CROSSES THE BRIDGE. The page collects it; the worklet opens the vault, derives the key, signs and submits. The mnemonic never reaches React Native, the WebView, or a log line — the same rule as mint, for the same reason. ⛔ PREVIEWS UNLESS TOLD TO SUBMIT. A PLC append is permanent and public, so the caller asks twice: once to see the exact change, once to make it. Both go through the same code path, because a confirm screen is only honest if what it describes is what will happen. That is also the shape a bridge web UI wants. A phone that PAIRED rather than minted has no vault, and says so: the recovery key stayed with whoever created the identity. There is no fallback that would be correct. The handler reports what it CANNOT do — fronting is the operator's half and needs the DID added to the bridge's frontedDids. Reporting success while nothing federates would be the same lie as "installed" meaning "running". The test proves the round trip that only exists on a handset: mint seals, this opens, and the recovered key is IN the identity's own rotationKeys — so the directory would accept an update signed by it. Deriving *a* key would have proven nothing. Plus the three refusals: wrong passphrase, wrong identity, no passphrase. Full suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8d988ca344e5bfafe969730592e7483f0c72d213
by plankatron <fleeky@gmail.com> · 2026-08-26T01:48:28.000Z

Changed files (4) +163 -2

lib/hosting.js +36 -0
……@@ -0,0 +1,36 @@
1// Accepting a bridge's invite, FROM THE PHONE — using the vault mint already sealed.
2//
3// The ceremony needs the recovery key, and a PLC update must be signed by a current rotation key, which
4// for a swarm identity is exactly one: the root recovery key. mint seals that under a passphrase at
5// creation time precisely so it can be used again later without anyone re-typing 24 words. This is the
6// "later".
7//
8// ⛔ THE PASSPHRASE IS THE ONLY THING THAT CROSSES THE BRIDGE. The page collects it; the worklet opens
9// the vault, derives the key, signs and submits. The mnemonic never reaches React Native, never reaches
10// the WebView, and never reaches a log line. Same rule as mint, for the same reason.
11//
12// ⛔ PREVIEW FIRST, ALWAYS. A PLC append is permanent and public, so the handler previews unless told to
13// submit — the shape hosting-cli.js established and the one a web UI needs, because a confirm screen is
14// only honest if the thing it describes is the thing that will happen.
15
16import path from 'bare-path'
17
18export { attachHosting, detachHosting } from 'atproto-identity/lib/hosting.js'
19export { describeChange, resolveSigner, hostingAttach, hostingDetach, hostingShow } from 'hyper-proto-peer/lib/hosting-cli.js'
20
21/**
22 * Where mint sealed this identity's recovery vault.
23 *
24 * Pure, and separate from the handler, because "can this phone accept an invite at all?" is answerable
25 * without opening anything — a UI needs to know whether to offer the control before it asks for a
26 * passphrase, and offering a control that cannot work is the bug this whole evening kept producing.
27 *
28 * Named by the DID's own suffix rather than a single vault.json: one device may hold several identities,
29 * and a shared filename silently means "the last one minted".
30 */
31export function vaultPathFor (hppDir, did) {
32 if (!hppDir || !did) throw new Error('vaultPathFor: hppDir and did required')
33 const suffix = String(did).split(':').pop()
34 if (!suffix) throw new Error('vaultPathFor: malformed did ' + did)
35 return path.join(hppDir, 'recovery', suffix + '.vault.json')
36}
lib/node.js +48 -0
……@@ -637,6 +637,54 @@ const handlers = {
637637 }
638638 },
639639
640 // ACCEPTING A BRIDGE'S INVITE — the other end of the split mint created.
641 //
642 // mint produces a P2P-only identity: no handle, no PDS, nobody's permission required. Being FRONTED by
643 // a membrane is what an invite grants, and accepting one is a PLC update adding a handle and a service
644 // endpoint. It must be signed by a current rotation key — the root recovery key — which mint sealed
645 // into the vault under a passphrase for exactly this moment.
646 //
647 // ⛔ THE PASSPHRASE IS ALL THAT CROSSES THE BRIDGE. The page collects it; this opens the vault, derives
648 // the key, signs and submits. The mnemonic never reaches React Native, the WebView, or a log line.
649 //
650 // ⛔ PREVIEWS UNLESS TOLD OTHERWISE. A PLC append is permanent and public. The caller asks twice: once
651 // to see the exact change, once to make it. A confirm screen is only honest if what it describes is
652 // what will happen, so both calls go through the same code path.
653 async hostingAttach ({ handle = null, pdsUrl = null, passphrase = null, submit = false, plcUrl = null } = {}) {
654 const did = (state.cfgDids && state.cfgDids[0] && state.cfgDids[0].did) || null
655 if (!did) throw new Error('this device has no identity to host — pair or create one first')
656 if (!handle) throw new Error('hosting requires the handle the invite grants')
657 if (!pdsUrl) throw new Error('hosting requires the service endpoint the invite grants')
658 if (!passphrase) throw new Error('hosting requires your vault passphrase — it is what unlocks the recovery key')
659
660 const { vaultPathFor, resolveSigner, hostingAttach: run } = await import('./hosting.js')
661 const vaultPath = vaultPathFor(dirs.hpp, did)
662 let envelope = null
663 try { envelope = JSON.parse(fs.readFileSync(vaultPath, 'utf8')) } catch {
664 // A phone that PAIRED rather than minted has no vault: the recovery key stayed with whoever created
665 // the identity. Saying so is the whole answer — there is no fallback that would be correct.
666 throw new Error('no recovery vault on this device — this identity was not created here, so its recovery key is on the device that made it')
667 }
668
669 // Fails identically for a wrong passphrase, a tampered blob and the wrong identity, deliberately.
670 const signer = resolveSigner({ vault: envelope, passphrase, did })
671 log('hosting: vault unlocked', { did, submit })
672
673 const r = await run({ did, handle, pdsUrl, signer, plcUrl, submit })
674 if (r.submitted) log('hosting: PLC updated — this identity now names a handle and a PDS', { did, handle, pdsUrl })
675 return {
676 did,
677 submitted: r.submitted,
678 preview: r.preview,
679 // ⛔ SAY WHAT IS STILL MISSING. Naming a PDS does not make the bridge serve you: fronting is the
680 // operator's half and cannot be done from here. A screen that reported success and left the user
681 // wondering why nothing federated would be the same lie as "installed" meaning "running".
682 operatorStepRemaining: r.submitted
683 ? 'the bridge operator must add ' + did + ' to frontedDids before it will front this identity'
684 : null
685 }
686 },
687
640688 // View a DID this node does not host: join its topic over P2P, verify its head, index it into the
641689 // SAME appview the hosted DIDs use. After this it is an ordinary account everywhere downstream —
642690 // author feed, thread, search — with no special case. Tracked so it survives a restart.
package.json +2 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "favela-android",
3 "version": "0.1.10",
3 "version": "0.1.11",
44 "description": "favela-android \u2014 a React Native client over a bare-kit worklet running a FULL favela node (hyper-proto-peer + appview + hyperswarm) on the phone. favela-web is reference for the logic, NOT the rendering: this is a different rendering surface. See spec/architecture.md.",
55 "private": true,
66 "type": "module",
……@@ -9,7 +9,7 @@
99 "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --out android/app/src/main/assets/worklet.bundle lib/node.js",
1010 "stage:addons": "node scripts/stage-addons.mjs",
1111 "prebuild:android": "npm run pack:worklet && npm run stamp:worklet && npm run gen:tree && npm run stage:addons",
12 "test": "bare test/first-run.test.mjs && bare test/bundle-by-name.test.mjs && bare test/repair-store-backend.test.mjs && bare test/mint.test.mjs && bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.test.mjs && bare test/lifecycle.test.mjs && bare test/roster.test.mjs && bare test/describe.test.mjs && bare test/shell.test.mjs && bare test/ota.test.mjs && bare test/ota-e2e.test.mjs",
12 "test": "bare test/first-run.test.mjs && bare test/hosting.test.mjs && bare test/bundle-by-name.test.mjs && bare test/repair-store-backend.test.mjs && bare test/mint.test.mjs && bare test/adopt.test.mjs && bare test/ipc.test.mjs && bare test/session.test.mjs && bare test/favela-api.test.mjs && bare test/lifecycle.test.mjs && bare test/roster.test.mjs && bare test/describe.test.mjs && bare test/shell.test.mjs && bare test/ota.test.mjs && bare test/ota-e2e.test.mjs",
1313 "stamp:worklet": "node -e \"const fs=require('fs');const p='android/app/src/main/assets/worklet.bundle';fs.writeFileSync(p+'.size',String(fs.statSync(p).size));fs.writeFileSync(p+'.built',String(Date.now()))\"",
1414 "gen:tree": "bare scripts/gen-tree-manifest.mjs",
1515 "gen:apk-tree": "bare scripts/gen-tree-manifest.mjs --in favela-android.apk --out apk.tree.json --name favela-android.apk"
test/hosting.test.mjs +77 -0
……@@ -0,0 +1,77 @@
1// The phone's half of the invite ceremony — unlocking the vault mint sealed.
2//
3// The PLC half is proven in atproto-identity (hosting-attach.test.js) and hyper-proto-peer
4// (hosting-cli.test.mjs) and is not re-tested here. What this file covers is the part that only exists
5// on a handset: finding the vault mint wrote, opening it with a passphrase, and getting back the key
6// that can actually sign a PLC update.
7//
8// ⛔ THE ROUND TRIP IS THE POINT. mint seals; this opens. If those two ever disagree about where the
9// vault lives or what it contains, the failure appears months later when someone is trying to recover an
10// identity — the worst possible time to discover a filename mismatch.
11//
12// Run: bare test/hosting.test.mjs
13import 'favela-web/lib/bare-globals.js'
14import fs from 'bare-fs'
15import os from 'bare-os'
16import path from 'bare-path'
17import { mintIdentity } from '../lib/mint.js'
18import { vaultPathFor, resolveSigner } from '../lib/hosting.js'
19import { sealVault } from 'atproto-identity/lib/vault.js'
20import { deriveAtprotoKey, ATPROTO_RECOVERY_PATH } from 'atproto-identity'
21
22let pass = 0
23const ok = (c, m) => { if (!c) throw new Error('FAIL: ' + m); console.log(' ok -', m); pass++ }
24
25const tmp = (n) => {
26 const d = path.join(os.tmpdir(), 'favela-hosting-' + n + '-' + Date.now())
27 fs.mkdirSync(d, { recursive: true })
28 return d
29}
30
31// ── the path agrees with what mint writes ────────────────────────────────────────────────────────
32{
33 const p = vaultPathFor('/data/hpp', 'did:plc:zl5loetx46p6xusgc47h3mia')
34 ok(p === '/data/hpp/recovery/zl5loetx46p6xusgc47h3mia.vault.json', 'vault path matches mint\'s layout: ' + p)
35 // Per-DID, not a shared vault.json — a device may hold several identities and the last mint must not
36 // silently overwrite an earlier one's recovery secret.
37 ok(vaultPathFor('/d', 'did:plc:aaa') !== vaultPathFor('/d', 'did:plc:bbb'), 'two identities get two vaults')
38 let threw = null
39 try { vaultPathFor('/d', '') } catch (e) { threw = e }
40 ok(threw, 'a missing did is refused rather than producing a wrong path')
41}
42
43// ── mint seals; hosting opens; they must agree ───────────────────────────────────────────────────
44{
45 const hpp = tmp('roundtrip')
46 const r = await mintIdentity({ configFile: path.join(hpp, 'config.json') })
47 const PASS = 'correct horse battery staple'
48
49 // Exactly what lib/node.js's mint handler does.
50 const dir = path.join(hpp, 'recovery')
51 fs.mkdirSync(dir, { recursive: true })
52 fs.writeFileSync(vaultPathFor(hpp, r.did), JSON.stringify(sealVault({ secret: r.rootMnemonic, passphrase: PASS, did: r.did })) + '\n')
53
54 // And exactly what the hosting handler does.
55 const envelope = JSON.parse(fs.readFileSync(vaultPathFor(hpp, r.did), 'utf8'))
56 const signer = resolveSigner({ vault: envelope, passphrase: PASS, did: r.did })
57
58 const expected = deriveAtprotoKey(r.rootMnemonic, ATPROTO_RECOVERY_PATH)
59 ok(signer.didKey === expected.didKey, 'the unlocked key is the RECOVERY key — the one a PLC update needs')
60
61 // ⛔ AND IT IS A ROTATION KEY OF THIS IDENTITY. Deriving *a* key proves nothing; the directory only
62 // accepts an update signed by a key already in the head op's rotationKeys.
63 ok(r.signedOp.rotationKeys.includes(signer.didKey), 'and it is in the identity\'s own rotationKeys, so the directory would accept it')
64}
65
66// ── the refusals ─────────────────────────────────────────────────────────────────────────────────
67{
68 const hpp = tmp('refuse')
69 const r = await mintIdentity({ configFile: path.join(hpp, 'config.json') })
70 const envelope = sealVault({ secret: r.rootMnemonic, passphrase: 'right', did: r.did })
71 const bad = (fn, m) => { try { fn(); ok(false, m) } catch { ok(true, m) } }
72 bad(() => resolveSigner({ vault: envelope, passphrase: 'wrong', did: r.did }), 'a wrong passphrase does not unlock it')
73 bad(() => resolveSigner({ vault: envelope, passphrase: 'right', did: 'did:plc:someoneelse' }), 'nor does the wrong identity')
74 bad(() => resolveSigner({ vault: envelope, did: r.did }), 'nor no passphrase at all')
75}
76
77console.log('\n' + pass + ' assertions passed')