| … | … | @@ -148,6 +148,11 @@ export const ATPROTO_OPS = { |
| 148 | 148 | const genesis = readJson(genesisPath(node, entry.did)) |
| 149 | 149 | return { |
| 150 | 150 | 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, |
| 151 | 156 | sealed: fs.existsSync(recoveryVaultPath(node, entry.did)), |
| 152 | 157 | submitted: !!(genesis && genesis.submittedAt), |
| 153 | 158 | // Whether a submit is even possible — without the genesis operation it is not, and saying so |
| … | … | @@ -156,6 +161,127 @@ export const ATPROTO_OPS = { |
| 156 | 161 | } |
| 157 | 162 | }, |
| 158 | 163 | |
| 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 | |
| 159 | 285 | /** |
| 160 | 286 | * ⛔ THE IRREVERSIBLE ONE. Appends the genesis to the global PLC log — no delete, no edit, and a |
| 161 | 287 | * test identity is forever. Its own separateness is the safeguard: nothing that mints can submit |