OverviewFilesCommitsIssuesDocsReleasesPeers

favela-host commits / db4cf5e

did-carry: ask a bridge to carry this identity, then put it on-chain
ONE VERB BECAUSE IT IS ONE DECISION. Fronting without adopting leaves a membrane holding a DID that resolves nowhere; adopting without fronting puts a handle and an endpoint on-chain, permanently, pointing at a server that has never heard of you. ⚠ Order is not arbitrary: fronting is reversible, the PLC update is not. The reversible step goes first, so a refusal costs nothing permanent. ⚠ One passphrase prompt, and there is no version without it. The update is signed by a ROTATION key derived from the recovery root; the signing key this node boots with deliberately cannot do it. Keeping the mnemonic unsealed to avoid the prompt would make 'a stolen device yields a locked vault' false. did-status now reports handle/pdsUrl, because a published DID with neither has nothing for bsky to fetch — 'published' and 'reachable' are different states. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
db4cf5e69eeff30043c0cd16d8a81115f9d05f5c
by plankatron <fleeky@gmail.com> · 2026-09-11T19:13:17.000Z

Changed files (3) +200 -1

lib/atproto.js +126 -0
……@@ -148,6 +148,11 @@ export const ATPROTO_OPS = {
148148 const genesis = readJson(genesisPath(node, entry.did))
149149 return {
150150 did: entry.did,
151 // ⛔ The handle and endpoint a bridge granted, if one has. Without them a published DID has
152 // `alsoKnownAs: []` and `services: {}` — nothing for Bluesky to fetch a repo from — so the page
153 // must be able to tell "published" from "reachable", which are not the same state at all.
154 handle: entry.handle || null,
155 pdsUrl: entry.pdsUrl || null,
151156 sealed: fs.existsSync(recoveryVaultPath(node, entry.did)),
152157 submitted: !!(genesis && genesis.submittedAt),
153158 // Whether a submit is even possible — without the genesis operation it is not, and saying so
……@@ -156,6 +161,127 @@ export const ATPROTO_OPS = {
156161 }
157162 },
158163
164 /**
165 * The whole thing, in one act: ask a membrane to carry this identity, then put what it granted
166 * on-chain.
167 *
168 * ⛔ ONE VERB BECAUSE IT IS ONE DECISION. Fronting without adopting leaves a membrane holding a DID
169 * that resolves nowhere; adopting without fronting puts a handle and an endpoint on-chain,
170 * permanently, pointing at a server that has never heard of you. Neither half is useful alone and
171 * the failure of either is expensive, so they are not offered separately to a person.
172 *
173 * ⚠ ORDER MATTERS AND IS NOT ARBITRARY. Fronting is reversible — a membrane can drop a DID. The PLC
174 * update is not. So the reversible step goes first: if the membrane refuses, nothing permanent has
175 * happened, and the name is still free.
176 *
177 * ⚠ ONE PASSPHRASE PROMPT, and there is no version without it. The update is signed by a ROTATION
178 * key, which derives from the recovery root; the signing key this node boots with deliberately
179 * cannot do it (identity.md §6.1.3 — a device can rotate without ever signing). Keeping the
180 * mnemonic unsealed to avoid the prompt would make "a stolen device yields a locked vault" false.
181 */
182 'did-carry': async (node, a) => {
183 const cfg = readJson(atprotoConfigPath(node))
184 const entry = cfg && Array.isArray(cfg.dids) ? cfg.dids[0] : null
185 if (!entry || !entry.did) throw new Error('this node has no identity for a bridge to carry')
186 if (!a.passphrase) throw new Error('this needs your recovery passphrase — it unlocks the rotation key, the only thing allowed to change this identity')
187
188 const { connectBridge, buildFront } = await import('favela-peer/lib/bridge.js')
189 const { rpc } = await connectBridge(node.swarm, a.bridge)
190
191 const offer = await rpc.request('policy', {})
192 if (!offer || !offer.pdsUrl) throw new Error('that bridge does not say where it serves from, so there is nothing safe to publish')
193
194 const { nonce } = await rpc.request('challenge', {})
195 const req = buildFront({ nonce, id: entry.did, root: node.rootHex(), cred: node.cred })
196 const r = await rpc.request('front', { ...req, name: a.name })
197 if (r.status !== 200 && r.status !== 202) throw new Error((r.body && r.body.error) || ('the bridge refused: ' + r.status))
198 const { handle, pdsUrl } = r.body
199 if (!handle) throw new Error('the bridge carried this identity but granted no handle, so there is nothing to publish')
200
201 // ⛔ THE IRREVERSIBLE HALF, and only now. Everything above can be undone by asking the bridge to
202 // drop us; from here the append is permanent and the previous state stays readable for ever.
203 const adopted = await ATPROTO_OPS['did-adopt'](node, { passphrase: a.passphrase, handle, pdsUrl, plcUrl: a.plcUrl })
204 return { ...adopted, carriedBy: a.bridge, pending: r.status === 202 ? (r.body.reason || true) : false }
205 },
206
207 /**
208 * Accept a bridge's offer: put its handle and its PDS endpoint onto this DID, on-chain.
209 *
210 * ⛔ WHY THIS IS THE MISSING HALF. A mint is P2P-ONLY by design — `buildGenesisOp` writes
211 * `alsoKnownAs: []` and `services: {}` when no handle or PDS is given, and hpp's mint says the two
212 * "arrive LATER, as a PLC update op, when an invite from atproto-bridge or ap-bridge is accepted".
213 * Nothing did that, so a published DID had no server for anyone to fetch a repo from and could not
214 * be resolved or followed no matter what else was true.
215 *
216 * ⛔ SIGNED BY A ROTATION KEY, WHICH IS NOT THE SIGNING KEY. That separation is the whole
217 * survivability story (identity.md §6.1.3): a device can rotate without ever signing, and the
218 * signing key in config.json deliberately cannot do this. The rotation key derives from the
219 * recovery root, which lives sealed — so this needs the passphrase, once, and there is no version
220 * of it that does not. Keeping the mnemonic unsealed would make "a stolen device yields a locked
221 * vault" false.
222 *
223 * ⛔ AND IT IS A SECOND PERMANENT APPEND. The PLC log is append-only: this is not editable and not
224 * removable, and the old state stays readable for ever. The caller confirms before reaching here.
225 *
226 * ⚠ CARRIES THE HEAD FORWARD. A PLC op replaces the WHOLE document state — it is not a patch — so
227 * every field not being changed is copied from the live head. Dropping one would silently delete
228 * it: `rotationKeys` in particular, which would be an unrecoverable loss of rotation authority.
229 */
230 'did-adopt': async (node, a) => {
231 const passphrase = typeof a.passphrase === 'string' ? a.passphrase : ''
232 const handle = String(a.handle || '').trim().toLowerCase()
233 const pdsUrl = String(a.pdsUrl || '').trim()
234 if (!passphrase) throw new Error('adopting a handle needs your recovery passphrase — it unlocks the rotation key, which is the only thing allowed to change this identity')
235 if (!/^[a-z0-9][a-z0-9.-]{0,252}[a-z0-9]$/.test(handle)) throw new Error('that is not a usable handle')
236 if (!/^https:\/\/[^\s]+$/.test(pdsUrl)) throw new Error('a PDS endpoint must be an https URL')
237
238 const cfg = readJson(atprotoConfigPath(node))
239 const entry = cfg && Array.isArray(cfg.dids) ? cfg.dids[0] : null
240 if (!entry || !entry.did) throw new Error('this node has no identity to adopt a handle onto')
241 const did = entry.did
242
243 // ⛔ The vault, not the config. The config holds the SIGNING key; this needs the ROTATION key,
244 // and only the sealed root can produce it.
245 const envelope = readJson(recoveryVaultPath(node, did))
246 if (!envelope) throw new Error('no recovery vault on this device, so the rotation key cannot be derived here')
247 const { openVault } = await import('atproto-identity/lib/vault.js')
248 let mnemonic
249 try {
250 mnemonic = openVault({ envelope, passphrase, did })
251 } catch {
252 // ⚠ Undistinguished on purpose, exactly as the vault is: telling a caller which half they got
253 // right tells an attacker the same thing.
254 throw new Error('could not open the recovery vault — check the passphrase')
255 }
256
257 const { deriveAtprotoKey, ATPROTO_RECOVERY_PATH, buildUpdateOp, signOp } = await import('atproto-identity/lib/atproto-identity.js')
258 const { PlcDirectory } = await import('atproto-identity/lib/plc-directory.js')
259 if (typeof globalThis.fetch !== 'function') await import('bare-fetch/global')
260
261 const rotation = deriveAtprotoKey(mnemonic, ATPROTO_RECOVERY_PATH)
262 const plc = new PlcDirectory(a.plcUrl ? { baseUrl: a.plcUrl } : {})
263 const head = await plc.getHead(did)
264
265 const unsigned = buildUpdateOp({
266 prevSignedOp: head,
267 // Carried forward, every one. A PLC op is the whole document state.
268 rotationDidKeys: head.rotationKeys,
269 verificationMethods: head.verificationMethods,
270 alsoKnownAs: ['at://' + handle],
271 services: { ...(head.services || {}), atproto_pds: { type: 'AtprotoPersonalDataServer', endpoint: pdsUrl } }
272 })
273 const signedOp = { ...unsigned, sig: signOp(unsigned, rotation.privateKey) }
274 await plc.submitUpdate({ did, signedOp })
275
276 // ⛔ RECORDED ONLY AFTER THE CHAIN ACCEPTED IT. The booted node reads handle/pdsUrl from here, and
277 // a config claiming a handle the directory does not carry is worse than one claiming none.
278 entry.handle = handle
279 entry.pdsUrl = pdsUrl
280 writeJson(atprotoConfigPath(node), cfg)
281
282 return { did, handle, pdsUrl, adopted: true }
283 },
284
159285 /**
160286 * ⛔ THE IRREVERSIBLE ONE. Appends the genesis to the global PLC log — no delete, no edit, and a
161287 * test identity is forever. Its own separateness is the safeguard: nothing that mints can submit
test/did-adopt.test.js +69 -0
……@@ -0,0 +1,69 @@
1// Accepting a bridge's handle — the step that makes a minted DID resolvable at all.
2//
3// ⛔ WHY IT WAS MISSING AND WHY THAT MATTERED. A mint is P2P-only by design: `buildGenesisOp` writes
4// `alsoKnownAs: []` and `services: {}` when given no handle or PDS, and hpp's mint says both "arrive
5// LATER, as a PLC update op, when an invite from atproto-bridge or ap-bridge is accepted". Nothing
6// performed that op, so a published DID had NO server for anyone to fetch a repo from — it could be
7// minted, published to the global directory, and still followable by nobody.
8//
9// ⚠ The submit itself is not tested: it is a second permanent append to a live global log, and a
10// test identity is forever. What IS tested is every refusal in front of it, which is where a mistake
11// would be silent rather than loud.
12
13import test from 'brittle'
14import fs from 'bare-fs'
15import os from 'bare-os'
16import process from 'bare-process'
17import { join } from 'bare-path'
18import { inProcessClient, recoveryVaultPath } from '../index.js'
19
20const PASS = 'a passphrase long enough'
21
22function fixture (t, name) {
23 const root = join(os.tmpdir(), 'fh-adopt-' + name + '-' + Date.now() + '-' + Math.random().toString(16).slice(2))
24 const home = join(root, 'home')
25 const storage = join(root, 'storage')
26 fs.mkdirSync(home, { recursive: true })
27 fs.mkdirSync(storage, { recursive: true })
28 const realHome = process.env.HOME
29 process.env.HOME = home
30 t.teardown(() => { process.env.HOME = realHome })
31 return { storage, node: { storage }, client: inProcessClient({ storage }) }
32}
33
34const ok = { passphrase: PASS, handle: 'alice.7rnx.net', pdsUrl: 'https://pds.7rnx.net' }
35
36test('⛔ it refuses without the passphrase — the rotation key is the only thing that may change a DID', async (t) => {
37 const { client } = fixture(t, 'nopass')
38 await client.call('did-mint', { passphrase: PASS })
39 await t.exception(() => client.call('did-adopt', { ...ok, passphrase: '' }), /passphrase/)
40})
41
42test('⛔ a wrong passphrase is refused, and says nothing about which half was wrong', async (t) => {
43 const { client } = fixture(t, 'badpass')
44 await client.call('did-mint', { passphrase: PASS })
45 await t.exception(() => client.call('did-adopt', { ...ok, passphrase: 'not it' }), /could not open the recovery vault/)
46})
47
48test('⛔ a node with no identity has nothing to adopt onto', async (t) => {
49 const { client } = fixture(t, 'noid')
50 await t.exception(() => client.call('did-adopt', ok), /no identity/)
51})
52
53test('⛔ garbage handles and non-https endpoints are refused before anything irreversible', async (t) => {
54 const { client } = fixture(t, 'garbage')
55 await client.call('did-mint', { passphrase: PASS })
56 await t.exception(() => client.call('did-adopt', { ...ok, handle: 'not a handle' }), /usable handle/)
57 await t.exception(() => client.call('did-adopt', { ...ok, pdsUrl: 'http://insecure.example' }), /https/)
58 await t.exception(() => client.call('did-adopt', { ...ok, pdsUrl: '' }), /https/)
59})
60
61// ⛔ The vault is what holds the rotation key. Without it this device can hold a DID and sign its
62// records and still be unable to change the identity — which is the correct, documented split, and
63// the message has to say so rather than failing as a missing file.
64test('⛔ a device with no recovery vault says why it cannot do this', async (t) => {
65 const { node, client } = fixture(t, 'novault')
66 const r = await client.call('did-mint', { passphrase: PASS })
67 fs.unlinkSync(recoveryVaultPath(node, r.did))
68 await t.exception(() => client.call('did-adopt', ok), /no recovery vault/)
69})
test/did-status.test.js +5 -1
……@@ -96,6 +96,10 @@ test('an already-submitted identity is not posted twice', async (t) => {
9696 t.ok((await client.call('did-status', {})).submitted, 'and status agrees')
9797})
9898
99// ⛔ Pinned so a verb cannot appear without a page that can reach it, or vanish from under one.
100// `did-carry` is the one a person uses; `did-adopt` is its irreversible half, exposed separately only
101// because a bridge that is already carrying you should not have to be asked twice.
99102test('the verb set is exactly what the pages expect', (t) => {
100 t.alike(Object.keys(ATPROTO_OPS).sort(), ['did-mint', 'did-status', 'did-submit'])
103 t.alike(Object.keys(ATPROTO_OPS).sort(),
104 ['did-adopt', 'did-carry', 'did-mint', 'did-status', 'did-submit'])
101105})