| … | … | @@ -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 | ||
| 16 | import path from 'bare-path' | |
| 17 | ||
| 18 | export { attachHosting, detachHosting } from 'atproto-identity/lib/hosting.js' | |
| 19 | export { 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 | */ | |
| 31 | export 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 | } |
| … | … | @@ -637,6 +637,54 @@ const handlers = { |
| 637 | 637 | } |
| 638 | 638 | }, |
| 639 | 639 | |
| 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 | ||
| 640 | 688 | // View a DID this node does not host: join its topic over P2P, verify its head, index it into the |
| 641 | 689 | // SAME appview the hosted DIDs use. After this it is an ordinary account everywhere downstream — |
| 642 | 690 | // author feed, thread, search — with no special case. Tracked so it survives a restart. |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-android", |
| 3 | "version": "0.1.10", | |
| 3 | "version": "0.1.11", | |
| 4 | 4 | "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.", |
| 5 | 5 | "private": true, |
| 6 | 6 | "type": "module", |
| … | … | @@ -9,7 +9,7 @@ |
| 9 | 9 | "pack:worklet": "bare-pack --preset android --defer bare-abstract-socket --out android/app/src/main/assets/worklet.bundle lib/node.js", |
| 10 | 10 | "stage:addons": "node scripts/stage-addons.mjs", |
| 11 | 11 | "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", | |
| 13 | 13 | "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()))\"", |
| 14 | 14 | "gen:tree": "bare scripts/gen-tree-manifest.mjs", |
| 15 | 15 | "gen:apk-tree": "bare scripts/gen-tree-manifest.mjs --in favela-android.apk --out apk.tree.json --name favela-android.apk" |
| … | … | @@ -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 | |
| 13 | import 'favela-web/lib/bare-globals.js' | |
| 14 | import fs from 'bare-fs' | |
| 15 | import os from 'bare-os' | |
| 16 | import path from 'bare-path' | |
| 17 | import { mintIdentity } from '../lib/mint.js' | |
| 18 | import { vaultPathFor, resolveSigner } from '../lib/hosting.js' | |
| 19 | import { sealVault } from 'atproto-identity/lib/vault.js' | |
| 20 | import { deriveAtprotoKey, ATPROTO_RECOVERY_PATH } from 'atproto-identity' | |
| 21 | ||
| 22 | let pass = 0 | |
| 23 | const ok = (c, m) => { if (!c) throw new Error('FAIL: ' + m); console.log(' ok -', m); pass++ } | |
| 24 | ||
| 25 | const 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 | ||
| 77 | console.log('\n' + pass + ' assertions passed') |