OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / web / static / signed-rpc.js

170 lines · javascript ref:
/**
 * signed-rpc — browser RPC helper that signs every call with canonical
 * anproto. Replaces the auth-challenge / auth-verify / session-cookie
 * dance with a per-call signed envelope.
 *
 * Wire format canonicalized in `spec/flows/auth-envelope.md`.
 *
 * Usage:
 *   import { signRpc, signedFetch, getKeypair } from './signed-rpc.js'
 *
 *   const result = await signRpc('create-issue', {
 *     repo: 'pear-git',
 *     title: 'bug'
 *   })
 *
 * Keypair lives in localStorage['pgv3-keypair']. `getKeypair()` returns
 * (and if needed creates) the 132-char canonical anproto keypair.
 *
 * Uses the vendored `./anproto-client.js` (pure-JS noble-ed25519 +
 * noble-sha512) so it works on HTTP (no crypto.subtle for signing)
 * and doesn't need a bare-specifier import map in the browser.
 */

/* global localStorage, fetch, crypto */

import { ANProtoClient } from './anproto-client.js'

const STORAGE_KEY = 'pgv3-keypair'
const RPC_ENDPOINT = ((document.body && document.body.dataset.prefix) || '') + '/api/rpc'   // server-side route lives at /api/rpc (see spec/flows/auth-envelope.md)
const NONCE_BYTES = 12

/**
 * Get the browser's anproto keypair. Lazy-creates on first call and
 * persists. 132-char canonical form: pubkey(44 b64) + secret(88 b64).
 *
 * @returns {Promise<string>} 132-char keypair
 */
export async function getKeypair () {
  let kp = null
  try { kp = localStorage.getItem(STORAGE_KEY) } catch {}
  if (kp && typeof kp === 'string' && kp.length === 132) return kp
  kp = await ANProtoClient.gen()
  try { localStorage.setItem(STORAGE_KEY, kp) } catch {}
  return kp
}

/**
 * Get the browser's public key (hex, 64 chars). Useful for display +
 * IdentityRegistry lookups.
 */
export async function getPublicKeyHex () {
  const kp = await getKeypair()
  return ANProtoClient.getPublicKeyHex(kp)
}

/**
 * Clear the browser's stored keypair. UI "sign out" action. Next call
 * to `getKeypair` generates a fresh identity.
 */
export function clearIdentity () {
  try { localStorage.removeItem(STORAGE_KEY) } catch {}
}

/**
 * Sign and send an RPC. Returns the decoded JSON response or throws.
 *
 * On transport failure (network error) → throws a `TypeError`.
 * On server-side envelope rejection → throws an Error with `.code`
 * (one of the EnvelopeError codes from envelope.js).
 *
 * @param {string} method   — RPC method name
 * @param {object} [params] — arguments (serialized into the signed payload)
 * @param {object} [opts]
 *   opts.keypair  — override the stored keypair (for tests)
 *   opts.endpoint — override /rpc (for proxied deployments)
 * @returns {Promise<any>}  — the decoded response JSON
 */
export async function signRpc (method, params = {}, opts = {}) {
  const keypair = opts.keypair || await getKeypair()
  const endpoint = opts.endpoint || RPC_ENDPOINT

  const nonce = randomNonceHex()
  const payload = JSON.stringify({ method, params, nonce })
  // ANProtoClient.sign(data, keypair) produces the canonical wire
  // format: pubkey(44) + base64(signature(64) || ts+hash(data)). That
  // matches what server's `an.open` + envelope verifier expect for
  // `sig` — the server re-hashes `payload`, compares, then trusts the
  // decoded method/params.
  const sig = await ANProtoClient.sign(payload, keypair)

  const envelope = { payload, sig }

  let res
  try {
    res = await fetch(endpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(envelope),
      credentials: 'same-origin'  // allow set-cookie from /api/rpc
    })
  } catch (err) {
    throw err
  }

  const bodyText = await res.text()
  let body
  try { body = bodyText ? JSON.parse(bodyText) : null } catch { body = null }

  if (!res.ok) {
    const err = new Error(
      (body && body.error) || 'RPC failed (status ' + res.status + ')'
    )
    err.status = res.status
    err.code = body && body.code
    err.body = body
    throw err
  }

  return body
}

/**
 * signedFetch — lower-level helper for callers who want the raw Response
 * object. Wraps arbitrary HTTP calls with envelope auth, e.g. for binary
 * uploads the server still requires caller identity for.
 *
 * NOT for GET routes; those are unauthenticated by design.
 *
 * @param {string} url
 * @param {object} init   — fetch init. `init.body` should be the envelope's
 *                           payload (stringified JSON object containing
 *                           the method-specific params + nonce).
 */
export async function signedFetch (url, init = {}) {
  const keypair = init.keypair || await getKeypair()
  const bodyStr = typeof init.body === 'string'
    ? init.body
    : JSON.stringify(init.body || {})

  const sig = await ANProtoClient.sign(bodyStr, keypair)

  const headers = Object.assign({}, init.headers || {}, {
    'Content-Type': 'application/json',
    'X-Pgv3-Sig': sig
  })

  return fetch(url, {
    ...init,
    body: bodyStr,
    headers,
    credentials: 'same-origin'
  })
}

// --- helpers ---

function randomNonceHex () {
  const bytes = new Uint8Array(NONCE_BYTES)
  crypto.getRandomValues(bytes)
  let hex = ''
  for (const b of bytes) hex += b.toString(16).padStart(2, '0')
  return hex
}

// Exposed on window for legacy code paths (and debugging)
if (typeof window !== 'undefined') {
  window.__pgv3SignRpc = signRpc
  window.__pgv3GetPublicKeyHex = getPublicKeyHex
}