OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / lib / hosting-cli.js

189 lines · javascript ref:
// The invite ceremony, driven from a terminal — and shaped so a bridge web UI can drive the same thing.
//
// atproto-identity/lib/hosting.js is the pure half: given the head op it returns a signed update that
// claims a handle and names a PDS. This adds the three things a real ceremony needs around it — find the
// current head, get the recovery key out of wherever it lives, and SHOW what is about to happen before
// anything permanent occurs.
//
// ⛔ THE SUBMIT IS THE IRREVERSIBLE HALF, SO IT IS OPT-IN. Every function here previews by default and
// only submits when explicitly told to. A PLC append cannot be undone, edited or hidden; a ceremony whose
// default action is "write to a global public log" is the wrong default for a person AND for a web UI,
// where the same button gets double-clicked. Preview → confirm is the shape both want.
//
// ⛔ THE MNEMONIC IS NEVER AN ARGUMENT. Anything in argv is in `ps` for every user on the box, and in
// shell history. It comes from a FILE, or from the sealed vault the phone already produces — which is
// the path that matters, because it is the one where the secret was never typed anywhere.
//
// WHAT A WEB UI WOULD REUSE: describeChange (the preview it renders), resolveSigner (the unlock step),
// and hosting.js's attach/detach. What it would NOT reuse is the operator half — adding the DID to the
// bridge's frontedDids — because that is the OTHER side of the ceremony and the whole point is that
// neither side can complete it alone.

import { deriveAtprotoKey, ATPROTO_RECOVERY_PATH } from 'atproto-identity'
import { PlcDirectory } from 'atproto-identity/lib/plc-directory.js'
import { openVault } from 'atproto-identity/lib/vault.js'
import { attachHosting, detachHosting } from 'atproto-identity/lib/hosting.js'

/**
 * A human-readable account of what an op changes about a DID document.
 *
 * Pure, so the preview a person approves is computed the same way whatever renders it — a terminal now,
 * a bridge web UI later. Reporting only what CHANGES is deliberate: a full document dump buries the one
 * line that matters, and this is the moment someone decides whether to append to a permanent log.
 */
export function describeChange (prevSignedOp, nextOp) {
  const before = prevSignedOp || {}
  const akaOf = (o) => ((o && o.alsoKnownAs) || []).join(', ') || '(none)'
  const pdsOf = (o) => (o && o.services && o.services.atproto_pds && o.services.atproto_pds.endpoint) || '(none)'
  const changes = []
  if (akaOf(before) !== akaOf(nextOp)) changes.push({ field: 'handle', from: akaOf(before), to: akaOf(nextOp) })
  if (pdsOf(before) !== pdsOf(nextOp)) changes.push({ field: 'pds', from: pdsOf(before), to: pdsOf(nextOp) })

  // Carried-forward fields are worth ASSERTING in the preview rather than assuming. A PLC op replaces the
  // whole document, so "unchanged" is a property of this particular op, not a law — and silently dropping
  // a rotation key is the one mistake nobody recovers from.
  const kept = {
    rotationKeys: JSON.stringify(before.rotationKeys) === JSON.stringify(nextOp.rotationKeys),
    verificationMethods: JSON.stringify(before.verificationMethods) === JSON.stringify(nextOp.verificationMethods)
  }
  return { changes, kept, prev: nextOp.prev }
}

/**
 * Get the recovery private key from wherever the holder keeps it.
 *
 * Two sources, and the vault is the one that matters: it is what `mint` seals on the phone, so the
 * secret can travel to another device without ever being typed. The mnemonic file exists because paper
 * is the other real backup and someone recovering from it has nothing else.
 *
 * ⛔ NOT FROM argv. See the header — argv is world-readable in ps.
 */
export function resolveSigner ({ mnemonic = null, vault = null, passphrase = null, did = null } = {}) {
  let phrase = mnemonic
  if (!phrase && vault) {
    if (!passphrase) throw new Error('hosting: a sealed vault needs its passphrase')
    if (!did) throw new Error('hosting: opening a vault needs the DID it was sealed for')
    // openVault fails identically for a wrong passphrase, a tampered blob and the wrong identity — by
    // design, so a failure does not tell an attacker which half they got right.
    phrase = openVault({ envelope: vault, passphrase, did })
  }
  if (!phrase) throw new Error('hosting: no recovery mnemonic — pass --mnemonic-file or --vault')
  const key = deriveAtprotoKey(String(phrase).trim(), ATPROTO_RECOVERY_PATH)
  return { privateKey: key.privateKey, didKey: key.didKey }
}

function directory (plcUrl) {
  return new PlcDirectory(plcUrl ? { baseUrl: plcUrl } : {})
}

/** What the directory currently says about this identity. */
export async function hostingShow ({ did, plcUrl = null } = {}) {
  if (!did) throw new Error('hostingShow: did required')
  const head = await directory(plcUrl).getHead(did)
  return {
    did,
    handle: ((head && head.alsoKnownAs) || [])[0] || null,
    pds: (head && head.services && head.services.atproto_pds && head.services.atproto_pds.endpoint) || null,
    rotationKeys: (head && head.rotationKeys) || [],
    head
  }
}

/**
 * Accept an invite: claim `handle`, name `pdsUrl`.
 *
 * Previews unless `submit` is true. Returns the signed op either way, so a caller that wants to show it,
 * store it, or hand it to a person for a second look can — the op is only meaningful once appended, and
 * holding it costs nothing.
 */
export async function hostingAttach ({ did, handle, pdsUrl, signer, plcUrl = null, submit = false } = {}) {
  if (!did) throw new Error('hostingAttach: did required')
  const plc = directory(plcUrl)
  const prevSignedOp = await plc.getHead(did)
  const op = attachHosting({ prevSignedOp, handle, pdsUrl, signerPrivateKey: signer.privateKey })
  const preview = describeChange(prevSignedOp, op)
  if (!submit) return { submitted: false, op, preview }
  const data = await plc.submitUpdate({ did, signedOp: op })
  return { submitted: true, op, preview, data }
}

/** Leave hosting, returning the identity to P2P-only. Same preview-by-default rule. */
export async function hostingDetach ({ did, signer, plcUrl = null, submit = false } = {}) {
  if (!did) throw new Error('hostingDetach: did required')
  const plc = directory(plcUrl)
  const prevSignedOp = await plc.getHead(did)
  const op = detachHosting({ prevSignedOp, signerPrivateKey: signer.privateKey })
  const preview = describeChange(prevSignedOp, op)
  if (!submit) return { submitted: false, op, preview }
  const data = await plc.submitUpdate({ did, signedOp: op })
  return { submitted: true, op, preview, data }
}

/** Render a preview the way the CLI prints it. Exported so the shape is testable. */
export function formatPreview ({ did, preview, submitted }) {
  const lines = []
  lines.push((submitted ? 'APPENDED to the PLC log for ' : 'WOULD append to the PLC log for ') + did)
  for (const c of preview.changes) lines.push('  ' + c.field + ': ' + c.from + '  ->  ' + c.to)
  if (!preview.changes.length) lines.push('  (no changes)')
  lines.push('  rotation keys: ' + (preview.kept.rotationKeys ? 'unchanged' : '⛔ CHANGED'))
  lines.push('  signing key:   ' + (preview.kept.verificationMethods ? 'unchanged' : '⛔ CHANGED'))
  if (!submitted) lines.push('\nThis is PERMANENT and PUBLIC once submitted. Re-run with --yes to append it.')
  return lines.join('\n')
}

/**
 * The `hosting-*` subcommands.
 *
 * ⛔ PREVIEW IS THE DEFAULT AND --yes IS THE ONLY WAY PAST IT. A PLC append is permanent, public and
 * unerasable, so the ceremony shows the exact change and stops. That is also the shape a web UI wants:
 * render the preview, then a confirm button posts the same call with submit:true.
 */
export async function hostingCommand (argv) {
  const sub = argv[0]
  const flag = (n) => { const i = argv.indexOf('--' + n); return i < 0 ? null : argv[i + 1] }
  const has = (n) => argv.includes('--' + n)
  const did = argv.find((a) => a.startsWith('did:'))
  const plcUrl = flag('plc')

  if (!did) {
    console.error('usage:\n' +
      '  hyper-proto-peer hosting-show   <did> [--plc <url>]\n' +
      '  hyper-proto-peer hosting-attach <did> --handle <h> --pds <url> (--mnemonic-file <f> | --vault <f> --passphrase-file <f>) [--yes]\n' +
      '  hyper-proto-peer hosting-detach <did> (--mnemonic-file <f> | --vault <f> --passphrase-file <f>) [--yes]\n\n' +
      'The secret is read from a FILE, never from the command line — argv is visible in ps.')
    return 1
  }

  if (typeof globalThis.fetch !== 'function') await import('bare-fetch/global')
  const fs = await import('bare-fs')

  if (sub === 'hosting-show') {
    const s = await hostingShow({ did, plcUrl })
    console.log(JSON.stringify({ did: s.did, handle: s.handle, pds: s.pds, rotationKeys: s.rotationKeys }, null, 2))
    return 0
  }

  const read = (p) => fs.default.readFileSync(p, 'utf8').trim()
  let signer
  try {
    signer = resolveSigner({
      mnemonic: flag('mnemonic-file') ? read(flag('mnemonic-file')) : null,
      vault: flag('vault') ? JSON.parse(read(flag('vault'))) : null,
      passphrase: flag('passphrase-file') ? read(flag('passphrase-file')) : null,
      did
    })
  } catch (e) { console.error(e.message); return 1 }

  const submit = has('yes')
  const r = sub === 'hosting-detach'
    ? await hostingDetach({ did, signer, plcUrl, submit })
    : await hostingAttach({ did, handle: flag('handle'), pdsUrl: flag('pds'), signer, plcUrl, submit })

  console.log(formatPreview({ did, preview: r.preview, submitted: r.submitted }))
  if (r.submitted) {
    console.log('\nNext, on the machine running the bridge: add this DID to `frontedDids` in its config.')
    console.log('That half is the OPERATOR\'s and cannot be done from here — which is the point.')
  }
  return 0
}