| … | … | @@ -0,0 +1,188 @@ |
| 1 | // The invite ceremony, driven from a terminal — and shaped so a bridge web UI can drive the same thing. |
| 2 | // |
| 3 | // atproto-identity/lib/hosting.js is the pure half: given the head op it returns a signed update that |
| 4 | // claims a handle and names a PDS. This adds the three things a real ceremony needs around it — find the |
| 5 | // current head, get the recovery key out of wherever it lives, and SHOW what is about to happen before |
| 6 | // anything permanent occurs. |
| 7 | // |
| 8 | // ⛔ THE SUBMIT IS THE IRREVERSIBLE HALF, SO IT IS OPT-IN. Every function here previews by default and |
| 9 | // only submits when explicitly told to. A PLC append cannot be undone, edited or hidden; a ceremony whose |
| 10 | // default action is "write to a global public log" is the wrong default for a person AND for a web UI, |
| 11 | // where the same button gets double-clicked. Preview → confirm is the shape both want. |
| 12 | // |
| 13 | // ⛔ THE MNEMONIC IS NEVER AN ARGUMENT. Anything in argv is in `ps` for every user on the box, and in |
| 14 | // shell history. It comes from a FILE, or from the sealed vault the phone already produces — which is |
| 15 | // the path that matters, because it is the one where the secret was never typed anywhere. |
| 16 | // |
| 17 | // WHAT A WEB UI WOULD REUSE: describeChange (the preview it renders), resolveSigner (the unlock step), |
| 18 | // and hosting.js's attach/detach. What it would NOT reuse is the operator half — adding the DID to the |
| 19 | // bridge's frontedDids — because that is the OTHER side of the ceremony and the whole point is that |
| 20 | // neither side can complete it alone. |
| 21 | |
| 22 | import { deriveAtprotoKey, ATPROTO_RECOVERY_PATH } from 'atproto-identity' |
| 23 | import { PlcDirectory } from 'atproto-identity/lib/plc-directory.js' |
| 24 | import { openVault } from 'atproto-identity/lib/vault.js' |
| 25 | import { attachHosting, detachHosting } from 'atproto-identity/lib/hosting.js' |
| 26 | |
| 27 | /** |
| 28 | * A human-readable account of what an op changes about a DID document. |
| 29 | * |
| 30 | * Pure, so the preview a person approves is computed the same way whatever renders it — a terminal now, |
| 31 | * a bridge web UI later. Reporting only what CHANGES is deliberate: a full document dump buries the one |
| 32 | * line that matters, and this is the moment someone decides whether to append to a permanent log. |
| 33 | */ |
| 34 | export function describeChange (prevSignedOp, nextOp) { |
| 35 | const before = prevSignedOp || {} |
| 36 | const akaOf = (o) => ((o && o.alsoKnownAs) || []).join(', ') || '(none)' |
| 37 | const pdsOf = (o) => (o && o.services && o.services.atproto_pds && o.services.atproto_pds.endpoint) || '(none)' |
| 38 | const changes = [] |
| 39 | if (akaOf(before) !== akaOf(nextOp)) changes.push({ field: 'handle', from: akaOf(before), to: akaOf(nextOp) }) |
| 40 | if (pdsOf(before) !== pdsOf(nextOp)) changes.push({ field: 'pds', from: pdsOf(before), to: pdsOf(nextOp) }) |
| 41 | |
| 42 | // Carried-forward fields are worth ASSERTING in the preview rather than assuming. A PLC op replaces the |
| 43 | // whole document, so "unchanged" is a property of this particular op, not a law — and silently dropping |
| 44 | // a rotation key is the one mistake nobody recovers from. |
| 45 | const kept = { |
| 46 | rotationKeys: JSON.stringify(before.rotationKeys) === JSON.stringify(nextOp.rotationKeys), |
| 47 | verificationMethods: JSON.stringify(before.verificationMethods) === JSON.stringify(nextOp.verificationMethods) |
| 48 | } |
| 49 | return { changes, kept, prev: nextOp.prev } |
| 50 | } |
| 51 | |
| 52 | /** |
| 53 | * Get the recovery private key from wherever the holder keeps it. |
| 54 | * |
| 55 | * Two sources, and the vault is the one that matters: it is what `mint` seals on the phone, so the |
| 56 | * secret can travel to another device without ever being typed. The mnemonic file exists because paper |
| 57 | * is the other real backup and someone recovering from it has nothing else. |
| 58 | * |
| 59 | * ⛔ NOT FROM argv. See the header — argv is world-readable in ps. |
| 60 | */ |
| 61 | export function resolveSigner ({ mnemonic = null, vault = null, passphrase = null, did = null } = {}) { |
| 62 | let phrase = mnemonic |
| 63 | if (!phrase && vault) { |
| 64 | if (!passphrase) throw new Error('hosting: a sealed vault needs its passphrase') |
| 65 | if (!did) throw new Error('hosting: opening a vault needs the DID it was sealed for') |
| 66 | // openVault fails identically for a wrong passphrase, a tampered blob and the wrong identity — by |
| 67 | // design, so a failure does not tell an attacker which half they got right. |
| 68 | phrase = openVault({ envelope: vault, passphrase, did }) |
| 69 | } |
| 70 | if (!phrase) throw new Error('hosting: no recovery mnemonic — pass --mnemonic-file or --vault') |
| 71 | const key = deriveAtprotoKey(String(phrase).trim(), ATPROTO_RECOVERY_PATH) |
| 72 | return { privateKey: key.privateKey, didKey: key.didKey } |
| 73 | } |
| 74 | |
| 75 | function directory (plcUrl) { |
| 76 | return new PlcDirectory(plcUrl ? { baseUrl: plcUrl } : {}) |
| 77 | } |
| 78 | |
| 79 | /** What the directory currently says about this identity. */ |
| 80 | export async function hostingShow ({ did, plcUrl = null } = {}) { |
| 81 | if (!did) throw new Error('hostingShow: did required') |
| 82 | const head = await directory(plcUrl).getHead(did) |
| 83 | return { |
| 84 | did, |
| 85 | handle: ((head && head.alsoKnownAs) || [])[0] || null, |
| 86 | pds: (head && head.services && head.services.atproto_pds && head.services.atproto_pds.endpoint) || null, |
| 87 | rotationKeys: (head && head.rotationKeys) || [], |
| 88 | head |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | /** |
| 93 | * Accept an invite: claim `handle`, name `pdsUrl`. |
| 94 | * |
| 95 | * Previews unless `submit` is true. Returns the signed op either way, so a caller that wants to show it, |
| 96 | * store it, or hand it to a person for a second look can — the op is only meaningful once appended, and |
| 97 | * holding it costs nothing. |
| 98 | */ |
| 99 | export async function hostingAttach ({ did, handle, pdsUrl, signer, plcUrl = null, submit = false } = {}) { |
| 100 | if (!did) throw new Error('hostingAttach: did required') |
| 101 | const plc = directory(plcUrl) |
| 102 | const prevSignedOp = await plc.getHead(did) |
| 103 | const op = attachHosting({ prevSignedOp, handle, pdsUrl, signerPrivateKey: signer.privateKey }) |
| 104 | const preview = describeChange(prevSignedOp, op) |
| 105 | if (!submit) return { submitted: false, op, preview } |
| 106 | const data = await plc.submitUpdate({ did, signedOp: op }) |
| 107 | return { submitted: true, op, preview, data } |
| 108 | } |
| 109 | |
| 110 | /** Leave hosting, returning the identity to P2P-only. Same preview-by-default rule. */ |
| 111 | export async function hostingDetach ({ did, signer, plcUrl = null, submit = false } = {}) { |
| 112 | if (!did) throw new Error('hostingDetach: did required') |
| 113 | const plc = directory(plcUrl) |
| 114 | const prevSignedOp = await plc.getHead(did) |
| 115 | const op = detachHosting({ prevSignedOp, signerPrivateKey: signer.privateKey }) |
| 116 | const preview = describeChange(prevSignedOp, op) |
| 117 | if (!submit) return { submitted: false, op, preview } |
| 118 | const data = await plc.submitUpdate({ did, signedOp: op }) |
| 119 | return { submitted: true, op, preview, data } |
| 120 | } |
| 121 | |
| 122 | /** Render a preview the way the CLI prints it. Exported so the shape is testable. */ |
| 123 | export function formatPreview ({ did, preview, submitted }) { |
| 124 | const lines = [] |
| 125 | lines.push((submitted ? 'APPENDED to the PLC log for ' : 'WOULD append to the PLC log for ') + did) |
| 126 | for (const c of preview.changes) lines.push(' ' + c.field + ': ' + c.from + ' -> ' + c.to) |
| 127 | if (!preview.changes.length) lines.push(' (no changes)') |
| 128 | lines.push(' rotation keys: ' + (preview.kept.rotationKeys ? 'unchanged' : '⛔ CHANGED')) |
| 129 | lines.push(' signing key: ' + (preview.kept.verificationMethods ? 'unchanged' : '⛔ CHANGED')) |
| 130 | if (!submitted) lines.push('\nThis is PERMANENT and PUBLIC once submitted. Re-run with --yes to append it.') |
| 131 | return lines.join('\n') |
| 132 | } |
| 133 | |
| 134 | /** |
| 135 | * The `hosting-*` subcommands. |
| 136 | * |
| 137 | * ⛔ PREVIEW IS THE DEFAULT AND --yes IS THE ONLY WAY PAST IT. A PLC append is permanent, public and |
| 138 | * unerasable, so the ceremony shows the exact change and stops. That is also the shape a web UI wants: |
| 139 | * render the preview, then a confirm button posts the same call with submit:true. |
| 140 | */ |
| 141 | export async function hostingCommand (argv) { |
| 142 | const sub = argv[0] |
| 143 | const flag = (n) => { const i = argv.indexOf('--' + n); return i < 0 ? null : argv[i + 1] } |
| 144 | const has = (n) => argv.includes('--' + n) |
| 145 | const did = argv.find((a) => a.startsWith('did:')) |
| 146 | const plcUrl = flag('plc') |
| 147 | |
| 148 | if (!did) { |
| 149 | console.error('usage:\n' + |
| 150 | ' hyper-proto-peer hosting-show <did> [--plc <url>]\n' + |
| 151 | ' hyper-proto-peer hosting-attach <did> --handle <h> --pds <url> (--mnemonic-file <f> | --vault <f> --passphrase-file <f>) [--yes]\n' + |
| 152 | ' hyper-proto-peer hosting-detach <did> (--mnemonic-file <f> | --vault <f> --passphrase-file <f>) [--yes]\n\n' + |
| 153 | 'The secret is read from a FILE, never from the command line — argv is visible in ps.') |
| 154 | return 1 |
| 155 | } |
| 156 | |
| 157 | if (typeof globalThis.fetch !== 'function') await import('bare-fetch/global') |
| 158 | const fs = await import('bare-fs') |
| 159 | |
| 160 | if (sub === 'hosting-show') { |
| 161 | const s = await hostingShow({ did, plcUrl }) |
| 162 | console.log(JSON.stringify({ did: s.did, handle: s.handle, pds: s.pds, rotationKeys: s.rotationKeys }, null, 2)) |
| 163 | return 0 |
| 164 | } |
| 165 | |
| 166 | const read = (p) => fs.default.readFileSync(p, 'utf8').trim() |
| 167 | let signer |
| 168 | try { |
| 169 | signer = resolveSigner({ |
| 170 | mnemonic: flag('mnemonic-file') ? read(flag('mnemonic-file')) : null, |
| 171 | vault: flag('vault') ? JSON.parse(read(flag('vault'))) : null, |
| 172 | passphrase: flag('passphrase-file') ? read(flag('passphrase-file')) : null, |
| 173 | did |
| 174 | }) |
| 175 | } catch (e) { console.error(e.message); return 1 } |
| 176 | |
| 177 | const submit = has('yes') |
| 178 | const r = sub === 'hosting-detach' |
| 179 | ? await hostingDetach({ did, signer, plcUrl, submit }) |
| 180 | : await hostingAttach({ did, handle: flag('handle'), pdsUrl: flag('pds'), signer, plcUrl, submit }) |
| 181 | |
| 182 | console.log(formatPreview({ did, preview: r.preview, submitted: r.submitted })) |
| 183 | if (r.submitted) { |
| 184 | console.log('\nNext, on the machine running the bridge: add this DID to `frontedDids` in its config.') |
| 185 | console.log('That half is the OPERATOR\'s and cannot be done from here — which is the point.') |
| 186 | } |
| 187 | return 0 |
| 188 | } |