| … | … | @@ -0,0 +1,142 @@ |
| 1 | // Attaching an identity to hosting, and detaching it again — the holder's half of the invite ceremony. |
| 2 | // |
| 3 | // ── WHY THIS IS A SEPARATE ACT FROM CREATING AN IDENTITY ───────────────────────────────────────── |
| 4 | // buildGenesisOp makes handle and pdsUrl optional, so an identity can be minted P2P-only: it names |
| 5 | // nobody's infrastructure and needs nobody's permission to exist (hyper-social #54 §8). What is scarce |
| 6 | // is not identity, it is somebody's servers. So being fronted by a membrane (pds.7rnx.net) or a |
| 7 | // fediverse bridge (ap.7rnx.net) is gated by an invite, and accepting one is THIS: a later PLC update |
| 8 | // adding a domain handle and a service endpoint. |
| 9 | // |
| 10 | // The ordering is forced rather than chosen. An update op must chain onto a genesis already in the log |
| 11 | // (checkUpdateOp wants prev == the head CID), so genesis is submitted at creation and the invite extends |
| 12 | // it. That is what keeps creation genuinely free and joining genuinely reversible. |
| 13 | // |
| 14 | // ── ⛔ THE OPERATOR CANNOT DO THIS FOR YOU ─────────────────────────────────────────────────────── |
| 15 | // A PLC update must be signed by a CURRENT rotation key, and createSwarmIdentity puts exactly one there: |
| 16 | // the root recovery key, which never leaves its owner (device-node.js: a device receives the signing key |
| 17 | // and NEVER the root, "so it can't derive the recovery key or self-recover"). An operator can grant |
| 18 | // infrastructure and can add a DID to the bridge's fronted set, but cannot alter someone's identity |
| 19 | // document. The ceremony is two-sided by construction, not by policy — which is the property that makes |
| 20 | // "our infrastructure" a service rather than custody. |
| 21 | // |
| 22 | // ── SHAPE, FOR THE UI THAT WILL DRIVE IT ───────────────────────────────────────────────────────── |
| 23 | // These are OFFLINE and PURE: they take the current head op and return a signed op. Nothing here |
| 24 | // resolves, submits, or talks to a network — PlcDirectory.submitUpdate does that, and it is deliberately |
| 25 | // somebody else's call, because it is the irreversible half. |
| 26 | // |
| 27 | // So an atproto-bridge web UI splits cleanly: the UI issues the invite and knows the handle + endpoint; |
| 28 | // the HOLDER (a phone unlocking its vault, or an operator with the mnemonic) calls these and submits. |
| 29 | // Neither side can complete the ceremony alone. |
| 30 | // |
| 31 | // A PLC op REPLACES the whole document state — it is not a patch — so every field not being changed must |
| 32 | // be carried forward explicitly. Dropping one silently removes a key or a service. |
| 33 | |
| 34 | import { buildUpdateOp, signOp, keyFromPrivate } from './atproto-identity.js' |
| 35 | |
| 36 | const PDS_SERVICE = 'atproto_pds' |
| 37 | const PDS_TYPE = 'AtprotoPersonalDataServer' |
| 38 | |
| 39 | const aka = (handle) => 'at://' + String(handle).replace(/^at:\/\//, '') |
| 40 | |
| 41 | /** |
| 42 | * Refuse locally what the directory would refuse remotely. |
| 43 | * |
| 44 | * Signing with a key that is not a current rotation key produces an op the directory rejects, and |
| 45 | * finding that out by submitting is a wasted round trip against a permanent public log — and a |
| 46 | * confusing one, because the rejection names a CID, not the mistake. The likely wrong key is the |
| 47 | * #atproto signing key, which a device legitimately holds and which has zero PLC authority. |
| 48 | */ |
| 49 | function assertRotationSigner (prevSignedOp, signerPrivateKey) { |
| 50 | const rot = (prevSignedOp && prevSignedOp.rotationKeys) || [] |
| 51 | let didKey = null |
| 52 | try { didKey = keyFromPrivate(signerPrivateKey).didKey } catch { didKey = null } |
| 53 | if (!didKey || !rot.includes(didKey)) { |
| 54 | throw new Error( |
| 55 | 'hosting: signer is not a current rotation key of this identity — a PLC update must be signed by ' + |
| 56 | 'the recovery key, not the #atproto signing key' |
| 57 | ) |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | /** Sign an unsigned op, after checking the signer may. */ |
| 62 | function finish (prevSignedOp, unsigned, signerPrivateKey) { |
| 63 | assertRotationSigner(prevSignedOp, signerPrivateKey) |
| 64 | return { ...unsigned, sig: signOp(unsigned, signerPrivateKey) } |
| 65 | } |
| 66 | |
| 67 | /** |
| 68 | * Accept hosting: claim `handle` and name `pdsUrl` as this identity's PDS. |
| 69 | * |
| 70 | * Also the MOVE operation — pointing at a different host is one op, not a detach plus an attach, because |
| 71 | * two ops would leave the identity briefly homeless in a log that never forgets. |
| 72 | * |
| 73 | * @param {object} opts |
| 74 | * @param {object} opts.prevSignedOp the current head op (genesis, or the last update) |
| 75 | * @param {string} opts.handle e.g. 'flaky.7rnx.net' |
| 76 | * @param {string} opts.pdsUrl e.g. 'https://pds.7rnx.net' |
| 77 | * @param {Uint8Array} opts.signerPrivateKey a CURRENT rotation key — the root recovery key |
| 78 | * @returns {object} a signed plc_operation, ready for PlcDirectory.submitUpdate |
| 79 | */ |
| 80 | export function attachHosting ({ prevSignedOp, handle, pdsUrl, signerPrivateKey } = {}) { |
| 81 | if (!prevSignedOp) throw new Error('attachHosting: prevSignedOp required') |
| 82 | if (!handle) throw new Error('attachHosting: handle required') |
| 83 | if (!pdsUrl) throw new Error('attachHosting: pdsUrl required') |
| 84 | |
| 85 | const prevServices = prevSignedOp.services || {} |
| 86 | const prevAka = prevSignedOp.alsoKnownAs || [] |
| 87 | const wantAka = [aka(handle)] |
| 88 | |
| 89 | // ⛔ REFUSE A NO-OP. Every accepted op is a permanent, public append to a global log. Re-running an |
| 90 | // invite that already succeeded must not add a second identical entry to someone's history — and a |
| 91 | // web UI WILL re-run it, because a user who does not see a change presses the button again. |
| 92 | const already = prevServices[PDS_SERVICE] && |
| 93 | prevServices[PDS_SERVICE].endpoint === pdsUrl && |
| 94 | prevAka.length === 1 && prevAka[0] === wantAka[0] |
| 95 | if (already) { |
| 96 | throw new Error('attachHosting: this identity is already hosted at ' + pdsUrl + ' as ' + handle + ' — nothing to append') |
| 97 | } |
| 98 | |
| 99 | // The handle REPLACES rather than accumulates: atproto treats the first alias as the identity's handle, |
| 100 | // and an identity that silently kept every host it ever tried would advertise handles that no longer |
| 101 | // resolve. Other services are carried forward — a community DID's feed generator and labeler are not |
| 102 | // this operation's business. |
| 103 | const unsigned = buildUpdateOp({ |
| 104 | prevSignedOp, |
| 105 | rotationDidKeys: prevSignedOp.rotationKeys, |
| 106 | verificationMethods: prevSignedOp.verificationMethods, |
| 107 | alsoKnownAs: wantAka, |
| 108 | services: { ...prevServices, [PDS_SERVICE]: { type: PDS_TYPE, endpoint: pdsUrl } } |
| 109 | }) |
| 110 | return finish(prevSignedOp, unsigned, signerPrivateKey) |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * Leave hosting: release the handle and stop naming a PDS, returning the identity to P2P-only. |
| 115 | * |
| 116 | * This is what makes accepting an invite reversible, and it is why the invite gates INFRASTRUCTURE |
| 117 | * rather than existence: leaving costs the identity nothing it was born with. The DID, its keys and its |
| 118 | * repo are untouched — only the document's claims about where to reach it change. |
| 119 | * |
| 120 | * ⚠ Reversible is not retroactive. Content already replicated to a membrane or a relay has been read, |
| 121 | * and detaching does not unsay it. This changes where the network is TOLD to look, nothing more. |
| 122 | */ |
| 123 | export function detachHosting ({ prevSignedOp, signerPrivateKey } = {}) { |
| 124 | if (!prevSignedOp) throw new Error('detachHosting: prevSignedOp required') |
| 125 | |
| 126 | const services = { ...(prevSignedOp.services || {}) } |
| 127 | const hadPds = !!services[PDS_SERVICE] |
| 128 | delete services[PDS_SERVICE] |
| 129 | const hadAka = (prevSignedOp.alsoKnownAs || []).length > 0 |
| 130 | if (!hadPds && !hadAka) { |
| 131 | throw new Error('detachHosting: this identity is already P2P-only — nothing to append') |
| 132 | } |
| 133 | |
| 134 | const unsigned = buildUpdateOp({ |
| 135 | prevSignedOp, |
| 136 | rotationDidKeys: prevSignedOp.rotationKeys, |
| 137 | verificationMethods: prevSignedOp.verificationMethods, |
| 138 | alsoKnownAs: [], |
| 139 | services |
| 140 | }) |
| 141 | return finish(prevSignedOp, unsigned, signerPrivateKey) |
| 142 | } |