OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / peer-client.js

163 lines · javascript ref:
/**
 * pgh peer client — call a pear-git-v3 daemon's /api/rpc over HTTP with
 * envelope-signed requests (Phase 3 Tier 2).
 *
 * Loads the local pgh identity (hyperident device + attestation chain,
 * or raw fallback), signs each call as a canonical anproto envelope,
 * and POSTs to the daemon. The server verifies via Phase 2b + 2d and
 * dispatches like any other envelope.
 *
 * Currently HTTP-only. A swarm-RPC transport (for NAT'd remote peers)
 * is a future enhancement — would require a `verified-dispatch` handler
 * on the daemon side so envelope auth is transport-agnostic. For LAN
 * and any host with reachable HTTP, this client is sufficient.
 *
 * Usage:
 *
 *   const client = await PghPeerClient.connect({
 *     url: 'http://tabertron.local:4040',
 *     identity: await loadOrCreateIdentity('/path/to/hyperident')
 *   })
 *
 *   const result = await client.call('list-my-devices', {})
 *   await client.close()
 */

import http from '#node:http'
import https from '#node:https'
import { homedir } from '#node:os'
import { join } from '#node:path'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import sodium from 'sodium-native'

import { loadOrCreateIdentity } from './identity.js'

const DEFAULT_HYPERIDENT_HOME = join(homedir(), '.hyperident')

// Inlined anproto signing to avoid a file: dep on anproto (which would
// inflate the L1 bootstrap tarball). Wire-compatible with the canonical
// anproto implementation used by pear-git-v3/lib/auth/envelope.js.
async function anHash (data) {
  const buf = typeof data === 'string' ? b4a.from(data, 'utf8') : data
  const out = b4a.alloc(32)
  sodium.crypto_hash_sha256(out, buf)
  return b4a.toString(out, 'base64')
}

async function anSign (hash, secretKey, publicKey) {
  const ts = Date.now()
  const message = b4a.from(String(ts) + hash, 'utf8')
  const sig = crypto.sign(message, secretKey) // detached, 64 bytes
  const combined = b4a.concat([sig, message])
  return b4a.toString(publicKey, 'base64') + b4a.toString(combined, 'base64')
}

export class PghPeerClient {
  /**
   * @param {object} opts
   * @param {string} opts.url          — daemon base URL (http[s]://host:port)
   * @param {object} [opts.identity]   — pre-loaded identity (from loadOrCreateIdentity);
   *                                     if omitted, loads from DEFAULT_HYPERIDENT_HOME
   * @param {number} [opts.timeout]    — per-request timeout ms (default 30s)
   */
  static async connect (opts = {}) {
    if (!opts.url) throw new Error('PghPeerClient.connect requires opts.url')
    const identity = opts.identity || await loadOrCreateIdentity(DEFAULT_HYPERIDENT_HOME)
    if (!identity.publicKey || !identity.secretKey) {
      throw new Error('PghPeerClient: identity has no device keypair — run `pgh init` first')
    }
    return new PghPeerClient({
      url: opts.url.replace(/\/+$/, ''),
      identity,
      timeout: opts.timeout || 30000
    })
  }

  constructor ({ url, identity, timeout }) {
    this.url = url
    this.identity = identity
    this.timeout = timeout
  }

  async _signEnvelope (method, params) {
    const nonce = b4a.toString(crypto.randomBytes(8), 'hex')
    const payload = JSON.stringify({ method, params: params || {}, nonce })
    const hash = await anHash(payload)
    const sig = await anSign(hash, this.identity.secretKey, this.identity.publicKey)
    const envelope = { payload, sig }
    if (this.identity.chain) envelope.chain = this.identity.chain
    return envelope
  }

  /**
   * Call a remote handler. Signs an envelope with the local identity,
   * POSTs /api/rpc, throws on non-200 or handler errors.
   *
   * @param {string} method
   * @param {object} [params]
   * @returns {Promise<any>}
   */
  async call (method, params = {}) {
    const envelope = await this._signEnvelope(method, params)
    const res = await postJson(this.url + '/api/rpc', envelope, this.timeout)
    if (res.status !== 200) {
      const msg = (res.body && res.body.error) || ('http ' + res.status)
      const err = new Error(msg)
      err.code = (res.body && res.body.code) || 'EHTTP'
      err.status = res.status
      throw err
    }
    if (!res.body || res.body.ok !== true) {
      const msg = (res.body && res.body.error) || 'server did not return ok'
      const err = new Error(msg)
      err.code = (res.body && res.body.code) || 'ENOTOK'
      throw err
    }
    return res.body.result
  }

  async close () {
    // Nothing persistent to release — POSTs are per-call.
  }
}

function postJson (url, body, timeoutMs) {
  return new Promise((resolve, reject) => {
    const json = JSON.stringify(body || {})
    const u = new URL(url)
    const isHttps = u.protocol === 'https:'
    const lib = isHttps ? https : http

    const req = lib.request({
      method: 'POST',
      hostname: u.hostname,
      port: u.port || (isHttps ? 443 : 80),
      path: u.pathname,
      headers: {
        'Content-Type': 'application/json',
        'Content-Length': b4a.byteLength(json),
        Accept: 'application/json'
      }
    }, (res) => {
      const parts = []
      res.on('data', (c) => parts.push(c))
      res.on('end', () => {
        const text = b4a.concat(parts).toString('utf8')
        let parsed
        try { parsed = JSON.parse(text) } catch { parsed = text }
        resolve({ status: res.statusCode, body: parsed })
      })
      res.on('error', reject)
    })

    const timer = setTimeout(() => {
      req.destroy(new Error('peer-client: request timed out after ' + timeoutMs + 'ms'))
    }, timeoutMs)
    req.on('close', () => clearTimeout(timer))
    req.on('error', reject)
    req.write(json)
    req.end()
  })
}