OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / commands / device.js

334 lines · javascript ref:
/**
 * pgh device <subcommand>
 *
 * Subcommands:
 *   invite [--label <name>]  — source device prints invite hex, waits
 *   join <inviteHex>         — target device joins, writes linked-device
 *
 * Security model: we never transfer the mnemonic. We attest a fresh
 * device keypair under the source's chain and transfer the device
 * keypair + extended chain over an ephemeral blind-pairing channel.
 * A compromised target can sign as its own device under the identity;
 * the root stays on the source machine.
 */

import process from '#node:process'
import { isAbstract } from '../daemon-client.js'
import fs from '#node:fs'
import path from '#node:path'
import { homedir } from '#node:os'
import { writeFile, mkdir, chmod } from '#node:fs/promises'
import Hyperswarm from 'hyperswarm'

import { loadOrCreateIdentity, LINKED_DEVICE_FILE } from '../identity.js'
import { createInvite, joinInvite } from '../device-linker.js'
import { PghPeerClient } from '../peer-client.js'

const HYPERIDENT_HOME = path.join(homedir(), '.hyperident')

export const usage = `pgh device — link and manage devices on your identity

  pgh device invite [--label <name>]       Print an invite hex, wait for join
  pgh device join <inviteHex> [--force]     Accept an invite (writes linked-device)
  pgh device list --peer <url>              Devices linked to your identity
  pgh device revoke <pubkey> --peer <url>
  pgh device label <pubkey> <name> --peer <url>

Note: this is PERSONAL device linking. For sister-machine mirroring use
the v3-extended 'pgh device invite --mirror' (needs a running daemon).`

export async function run (_client, args, globals = {}) {
  const sub = args[0]
  if (!sub) throw new Error(usage)
  if (sub === 'invite') return runInvite(args.slice(1), globals)
  if (sub === 'join') return runJoin(args.slice(1), globals)
  if (sub === 'list') return runList(args.slice(1), globals)
  if (sub === 'revoke') return runRevoke(args.slice(1), globals)
  if (sub === 'label') return runLabel(args.slice(1), globals)
  throw new Error('unknown device subcommand: ' + sub + ' (supported: invite, join, list, revoke, label)')
}

/**
 * Pull out --peer <url> from an args array. Returns [url | null, argsWithoutPeer].
 * --peer is required for list/revoke/label since there's no local registry.
 */
function extractPeer (args) {
  const i = args.indexOf('--peer')
  if (i === -1) return [null, args]
  const url = args[i + 1]
  if (!url) throw new Error('--peer requires a URL (e.g. --peer http://host:4040)')
  return [url, [...args.slice(0, i), ...args.slice(i + 2)]]
}

async function runList (args, globals) {
  const [peer] = extractPeer(args)
  if (!peer) throw new Error('usage: pgh device list --peer <url>')

  const client = await PghPeerClient.connect({ url: peer })
  try {
    const result = await client.call('list-my-devices', {})
    const devices = (result && result.devices) || []
    if (globals.format === 'json') return devices

    if (devices.length === 0) return '(no devices linked to this identity yet)'
    const lines = ['devices on peer ' + peer + ':']
    for (const d of devices) {
      const when = d.firstLinkedAt ? new Date(d.firstLinkedAt).toISOString() : '-'
      const seen = d.lastSeenAt ? new Date(d.lastSeenAt).toISOString() : '-'
      const status = d.revoked ? 'REVOKED' : 'active'
      lines.push('  ' + d.publicKey.slice(0, 16) + '…' +
        '  label=' + (d.label || '-') +
        '  status=' + status +
        '  linked=' + when +
        '  lastSeen=' + seen)
    }
    return lines.join('\n')
  } finally {
    await client.close()
  }
}

async function runRevoke (args, globals) {
  const [peer, rest] = extractPeer(args)
  if (!peer || !rest[0]) throw new Error('usage: pgh device revoke <devicePubkey> --peer <url>')
  const devicePubkey = rest[0]

  const client = await PghPeerClient.connect({ url: peer })
  try {
    const result = await client.call('revoke-device', { devicePubkey })
    if (globals.format === 'json') return result
    return 'revoked: ' + devicePubkey.slice(0, 16) + '…'
  } finally {
    await client.close()
  }
}

async function runLabel (args, globals) {
  const [peer, rest] = extractPeer(args)
  if (!peer || rest.length < 2) {
    throw new Error('usage: pgh device label <devicePubkey> <label> --peer <url>')
  }
  const devicePubkey = rest[0]
  const label = rest.slice(1).join(' ')

  const client = await PghPeerClient.connect({ url: peer })
  try {
    const result = await client.call('label-device', { devicePubkey, label })
    if (globals.format === 'json') return result
    return 'labeled ' + devicePubkey.slice(0, 16) + '…' + ' as "' + label + '"'
  } finally {
    await client.close()
  }
}

// Resolve a daemon's UDS via pgh-sidecar's app-lookup. Used by --mirror to
// route the invite through the daemon (which is the only place that knows
// the autobase bootstrap keys + repo-tracker map needed for the bundle).
async function daemonClient (appName) {
  const SIDECAR_SOCK = process.env.PGH_SIDECAR_SOCK || path.join(homedir(), '.pgh', 'svc.sock')
  if (!isAbstract(SIDECAR_SOCK) && !fs.existsSync(SIDECAR_SOCK)) {
    throw new Error('pgh-sidecar is not running (no socket at ' + SIDECAR_SOCK + ')')
  }
  const { Client } = await import('hyper-svc')
  const sc = await Client.connectUds(SIDECAR_SOCK)
  let entry
  try { entry = await sc.call('app-lookup', { name: appName }) } finally { try { await sc.close() } catch {} }
  if (!entry || !entry.found) {
    throw new Error(`app '${appName}' not registered with pgh-sidecar (is it running?)`)
  }
  return Client.connectUds(entry.udsPath)
}

async function runMirrorInvite (args, globals) {
  // --mirror routes through the daemon's device-invite RPC. The daemon
  // builds a deploymentBundle (autobase bootstrap keys + repo-tracker
  // map + swarm topic + sourceServerKey), runs blind-pairing itself,
  // and returns inviteHex. We poll device-invite-status until linked.
  const labelIdx = args.indexOf('--label')
  const label = (labelIdx !== -1 && args[labelIdx + 1]) || null
  const appIdx = args.indexOf('--app')
  const appName = (appIdx !== -1 && args[appIdx + 1]) || 'pear-git-v3'
  const quiet = globals.format === 'json'

  const client = await daemonClient(appName)
  try {
    const res = await client.call('device-invite', { mirror: true, label })
    const inviteHex = res.inviteHex || res.invite
    if (!inviteHex) throw new Error('daemon did not return inviteHex: ' + JSON.stringify(res))

    if (!quiet) {
      process.stderr.write('pgh device invite --mirror\n')
      process.stderr.write('  app:   ' + appName + '\n')
      process.stderr.write('  label: ' + (label || '(none)') + '\n')
      process.stderr.write('  paste this on the target device:\n\n')
      process.stderr.write('    pgh device join ' + inviteHex + '\n\n')
      process.stderr.write('  waiting (daemon driving blind-pairing)...\n')
    }

    const deadline = Date.now() + 5 * 60 * 1000
    while (Date.now() < deadline) {
      await new Promise(r => setTimeout(r, 2000))
      const status = await client.call('device-invite-status', { inviteHex })
      if (!status || status.status === 'waiting') continue
      if (status.status === 'linked') {
        if (!quiet) {
          const dh = status.devicePublicKeyHex || ''
          process.stderr.write('  linked device: ' + dh.slice(0, 16) + '…  label=' + (label || '(none)') + '\n')
        }
        return { ok: true, invite: inviteHex, newDevicePubkey: status.devicePublicKeyHex, label }
      }
      if (status.status === 'failed') {
        throw new Error('mirror invite failed: ' + (status.error || 'unknown'))
      }
    }
    throw new Error('mirror invite did not complete within 5 minutes')
  } finally {
    try { await client.close() } catch {}
  }
}

async function runInvite (args, globals) {
  // Mirror flag routes through the daemon (it's the only side that knows
  // the autobase bootstrap keys + repo map). Pure user-device invite is
  // local-only.
  if (args.includes('--mirror')) return runMirrorInvite(args, globals)

  const labelIdx = args.indexOf('--label')
  const label = (labelIdx !== -1 && args[labelIdx + 1]) || null

  const identity = await loadOrCreateIdentity(HYPERIDENT_HOME)
  if (!identity.chain) {
    throw new Error('this pgh identity has no attestation chain — nothing to link from. Run on a device with a hyperident identity.')
  }

  const swarm = new Hyperswarm()
  const quiet = globals.format === 'json'

  try {
    const { invite, linked, close } = await createInvite({
      swarm,
      sourceIdentity: identity,
      label
    })

    if (!quiet) {
      process.stderr.write('pgh device invite\n')
      process.stderr.write('  label: ' + (label || '(none)') + '\n')
      process.stderr.write('  paste this on the target device (valid until you Ctrl-C):\n\n')
      process.stderr.write('    pgh device join ' + invite + '\n\n')
      process.stderr.write('  waiting for a device to connect...\n')
    }

    const result = await linked

    // Drain every swarm connection's write buffer before closing. The
    // blind-pairing confirm response (which carries the device bundle to
    // the candidate) is enqueued via protomux when the user's onadd
    // returns, but by then `linked` has already resolved. Without this
    // flush, swarm.destroy() tears down the noise stream before the
    // bundle frame is transmitted — candidate's onadd never fires,
    // candidate times out at PAIRING_TIMEOUT_MS thinking nothing happened.
    //
    // secret-stream.flush() awaits Writable.drained(stream) +
    // rawStream.flush() which is exactly the right semantics here.
    const flushes = []
    for (const conn of swarm.connections) {
      if (typeof conn.flush === 'function') flushes.push(conn.flush())
    }
    await Promise.all(flushes).catch(() => {})

    try { await close() } catch {}

    if (!quiet) {
      process.stderr.write('  linked device: ' + result.newDevicePubkeyHex.slice(0, 16) + '…  label=' + (result.label || '(none)') + '\n')
    }

    return {
      ok: true,
      invite,
      newDevicePubkey: result.newDevicePubkeyHex,
      label: result.label
    }
  } finally {
    try { await swarm.destroy() } catch {}
  }
}

async function runJoin (args, globals) {
  const invite = args[0]
  if (!invite) throw new Error('usage: pgh device join <inviteHex>')

  const forceIdx = args.indexOf('--force')
  const force = forceIdx !== -1

  if (fs.existsSync(LINKED_DEVICE_FILE) && !force) {
    throw new Error(
      'linked-device file already exists at ' + LINKED_DEVICE_FILE +
      ' — pass --force to overwrite'
    )
  }

  const quiet = globals.format === 'json'
  const swarm = new Hyperswarm()

  try {
    if (!quiet) process.stderr.write('pgh device join — connecting...\n')
    const payload = await joinInvite({ swarm, invite })

    await mkdir(path.dirname(LINKED_DEVICE_FILE), { recursive: true })
    await writeFile(LINKED_DEVICE_FILE, JSON.stringify(payload, null, 2) + '\n', { mode: 0o600 })
    try { await chmod(LINKED_DEVICE_FILE, 0o600) } catch {}

    // Mirror-invite side-effect: when the source included a
    // deploymentBundle (`pgh device invite --mirror`), persist it to
    // <PEAR_GIT_STORAGE>/origin-bundle.json so pear-git-v3 auto-boots
    // joined to the source's rooms on next start. Backed-out to
    // <storage>/mirror-bundle.json for pre-Phase-3 daemons that still
    // read the legacy name.
    let originBundlePath = null
    if (payload.deploymentBundle) {
      const storage = process.env.PEAR_GIT_STORAGE ||
        path.join(process.env.HOME || '.', 'pgv3-storage')
      await mkdir(storage, { recursive: true })
      const bundle = {
        // Field names expected by pear-git-v3's server.js +
        // expandDeploymentBundle. chainB64 == deviceProof (both names
        // present for cross-version compat).
        deviceProof: payload.chainB64,
        chainB64: payload.chainB64,
        rootPubkey: payload.rootPubkeyHex,
        devicePublicKey: payload.devicePublicKeyHex,
        deviceSecretKey: payload.deviceSecretKeyHex,
        ...payload.deploymentBundle,
        acceptedAt: new Date().toISOString()
      }
      const json = JSON.stringify(bundle, null, 2) + '\n'
      originBundlePath = path.join(storage, 'origin-bundle.json')
      await writeFile(originBundlePath, json)
      // Compat alias — older server.js looks for mirror-bundle.json too
      await writeFile(path.join(storage, 'mirror-bundle.json'), json)
    }

    if (!quiet) {
      process.stderr.write('  wrote ' + LINKED_DEVICE_FILE + '\n')
      process.stderr.write('  root pubkey:   ' + payload.rootPubkeyHex + '\n')
      process.stderr.write('  device pubkey: ' + payload.devicePublicKeyHex + '\n')
      process.stderr.write('  label:         ' + (payload.label || '(none)') + '\n')
      if (originBundlePath) {
        process.stderr.write('  origin bundle: ' + originBundlePath + '\n')
      }
    }

    return {
      ok: true,
      path: LINKED_DEVICE_FILE,
      originBundle: originBundlePath,
      rootPubkey: payload.rootPubkeyHex,
      devicePubkey: payload.devicePublicKeyHex,
      label: payload.label
    }
  } finally {
    try { await swarm.destroy() } catch {}
  }
}