OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / device-linker.js

299 lines · javascript ref:
/**
 * Device Linker — Blind-pairing transport for device-chain exchange (Phase 2c).
 *
 * Security improvement over pear-git v1's DeviceLinker:
 *   v1 transfers the full BIP39 mnemonic → compromised target leaks root
 *   v3 transfers a device-scoped keypair + attestation chain → compromised
 *      target only has its own device key, never the root
 *
 * Uses blind-pairing over Hyperswarm. Each side creates a short-lived
 * Hyperswarm instance for the pairing session (no persistent swarm needed).
 *
 * Flow:
 *   Source (has identity root):
 *     1. Load own device chain from hyperident vault
 *     2. Generate fresh keypair for the target device
 *     3. IdentityKey.attestDevice(newPubkey, sourceKp, sourceProof) → newProof
 *     4. Create blind-pairing invite, return inviteHex
 *     5. On candidate connect: send { deviceSecretKey, deviceProof, rootPubkey, label }
 *
 *   Target (paste inviteHex):
 *     1. Connect via blind-pairing invite
 *     2. Receive device bundle
 *     3. Build anproto keypair from device key
 *     4. Sign envelope with chain → IdentityRegistry links device to root
 */

import Hyperswarm from 'hyperswarm'
import BlindPairing from 'blind-pairing'
import crypto from 'hypercore-crypto'
import IdentityKey from 'keet-identity-key'
import Hypercore from 'hypercore'
import b4a from 'b4a'

const PAIRING_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes

export class DeviceLinker {
  /**
   * @param {object} opts
   * @param {object} [opts.swarm]   — external Hyperswarm (optional; ephemeral created if omitted)
   * @param {object} [opts.deviceChain] — { keyPair, proof, metadata } from hyperident vault
   * @param {string} [opts.rootPubkeyHex] — identity root public key (hex)
   */
  constructor (opts = {}) {
    this._externalSwarm = opts.swarm || null
    this._swarm = null
    this._pairing = null
    this._timeout = null

    // Source-side fields (set for createInvite)
    this._deviceChain = opts.deviceChain || null
    this._rootPubkeyHex = opts.rootPubkeyHex || null
  }

  /**
   * Create an invite for device linking (source side).
   *
   * Caller must pass the source's device chain + root pubkey in the
   * constructor, or set them here via opts.
   *
   * @param {object} [opts]
   * @param {string} [opts.label] — label for the new device (e.g. "laptop-pgh")
   * @param {object} [opts.deploymentBundle] — optional room bundle for
   *   mirror setup. When present, the accept-side daemon can boot joined
   *   to this deployment's rooms. Shape:
   *     {
   *       orgs:          { bootstrapKey: hex },
   *       identity:      { bootstrapKey: hex },
   *       releases:      { bootstrapKey: hex },   // pgh release-registry
   *       issueRegistry: { bootstrapKey: hex },   // hypercore + hyperbee
   *       repoEventLog:  { coreKey: hex },
   *       swarm:         { topic: hex }
   *     }
   *   For pure user device linking (human laptop, browser), omit this.
   * @returns {Promise<{ inviteHex: string, waitForPeer: () => Promise<{ devicePublicKeyHex: string }> }>}
   */
  async createInvite (opts = {}) {
    const deviceChain = this._deviceChain
    if (!deviceChain || !deviceChain.keyPair || !deviceChain.proof) {
      throw new Error('createInvite requires source deviceChain (keyPair + proof)')
    }
    const rootHex = this._rootPubkeyHex
    if (!rootHex) {
      throw new Error('createInvite requires rootPubkeyHex')
    }

    const label = opts.label || 'linked-device'

    // Generate a fresh Ed25519 keypair for the target device. The
    // target uses this for BOTH envelope signing AND as its Autobase
    // writer keypair — one key, one identity-registered core.
    const newDeviceKp = crypto.keyPair()

    // Attest the new device: the source's device key vouches for the
    // new device's public key. Chain links newDevice → source → root.
    const newProof = IdentityKey.attestDevice(
      newDeviceKp.publicKey,
      deviceChain.keyPair,
      deviceChain.proof
    )

    // Pre-compute the manifest-derived hypercore key the target's
    // Autobase-local core will have when it boots with `keyPair:
    // newDeviceKp`. Matching autobase's boot.js + corestore's default
    // manifestVersion (1). Sharing this with the caller lets it
    // pre-authorize the writer via ADD_WRITER BEFORE the target comes
    // online — so the first append the target makes already lands in
    // the linearized view.
    const autobaseLocalKey = Hypercore.key({
      version: 1,
      signers: [{ publicKey: newDeviceKp.publicKey }]
    })
    const autobaseLocalKeyHex = b4a.toString(autobaseLocalKey, 'hex')

    // The bundle we'll send over blind-pairing.
    const payload = {
      deviceSecretKey: b4a.toString(newDeviceKp.secretKey, 'hex'),
      devicePublicKey: b4a.toString(newDeviceKp.publicKey, 'hex'),
      deviceProof: b4a.toString(newProof, 'base64'),
      rootPubkey: rootHex,
      label,
      // Advertised so the target can confirm which writer key the
      // source registered on its behalf (informational; the target
      // re-derives identically from deviceSecretKey).
      autobaseLocalKey: autobaseLocalKeyHex
    }
    if (opts.deploymentBundle) {
      payload.deploymentBundle = opts.deploymentBundle
    }
    const bundle = JSON.stringify(payload)
    const bundleBuf = b4a.from(bundle, 'utf-8')

    // Create blind-pairing invite.
    const pairingKey = crypto.keyPair()
    const inviteResult = BlindPairing.createInvite(pairingKey.publicKey)
    const { invite, publicKey: invitePublicKey, discoveryKey, seed: inviteSeed } = inviteResult
    const inviteKeyPair = crypto.keyPair(inviteSeed)

    // Ephemeral swarm for this pairing session.
    this._swarm = this._externalSwarm || new Hyperswarm()
    this._pairing = new BlindPairing(this._swarm)

    const peerConnected = new Promise((resolve, reject) => {
      this._timeout = setTimeout(() => {
        reject(new Error('Timeout waiting for peer'))
      }, PAIRING_TIMEOUT_MS)

      this._pairing.addMember({
        discoveryKey,
        onadd: (request) => {
          clearTimeout(this._timeout)
          this._timeout = null

          try {
            // Open the request — userData is the joining device's hello.
            request.open(invitePublicKey)

            // Confirm — send the device bundle as additional data, signed
            // with the invite keypair (blind-pairing verifies the sig).
            request.confirm({
              key: pairingKey.publicKey,
              encryptionKey: null,
              additional: {
                data: bundleBuf,
                signature: crypto.sign(bundleBuf, inviteKeyPair.secretKey)
              }
            })

            resolve({
              devicePublicKeyHex: b4a.toString(newDeviceKp.publicKey, 'hex'),
              autobaseLocalKeyHex
            })
          } catch (err) {
            reject(err)
          }
        }
      })
    })

    return {
      invite,
      inviteHex: b4a.toString(invite, 'hex'),
      // The target's pre-computed Autobase writer key. Caller MUST
      // append an ADD_WRITER op for this key BEFORE the target comes
      // online, or the target's first append won't land in the view.
      autobaseLocalKeyHex,
      newDevicePubkeyHex: b4a.toString(newDeviceKp.publicKey, 'hex'),
      waitForPeer: () => peerConnected
    }
  }

  /**
   * Join with an invite (target side).
   *
   * Connects to the source device via blind-pairing, receives the device
   * bundle (keypair + chain proof + root). Returns the bundle — caller
   * is responsible for storing/registering it.
   *
   * @param {string} inviteHex — hex-encoded invite from source
   * @returns {Promise<{ deviceSecretKey: string, devicePublicKey: string, deviceProof: string, rootPubkey: string, label: string, anKeypair: string }>}
   */
  async joinWithInvite (inviteHex) {
    if (!inviteHex || typeof inviteHex !== 'string') {
      throw new Error('inviteHex is required')
    }

    const inviteBuf = b4a.from(inviteHex, 'hex')

    // Hello payload — just our ephemeral public key so the source knows
    // who connected (not required for auth, but useful for logging).
    const helloKp = crypto.keyPair()

    this._swarm = this._externalSwarm || new Hyperswarm()
    this._pairing = new BlindPairing(this._swarm)

    const result = await new Promise((resolve, reject) => {
      this._timeout = setTimeout(() => {
        reject(new Error('Timeout waiting for source device'))
      }, PAIRING_TIMEOUT_MS)

      this._pairing.addCandidate({
        invite: inviteBuf,
        userData: helloKp.publicKey,
        onadd: (auth) => {
          clearTimeout(this._timeout)
          this._timeout = null

          try {
            const dataBuf = auth.data
            if (!dataBuf || dataBuf.length === 0) {
              reject(new Error('No device bundle received from source'))
              return
            }

            const bundle = JSON.parse(b4a.toString(dataBuf, 'utf-8'))
            resolve(bundle)
          } catch (err) {
            reject(err)
          }
        }
      })
    })

    // Build anproto keypair string from the received device key.
    // Format: base64(pubkey 32B) + base64(secretKey 64B) = 132 chars.
    const pubBuf = b4a.from(result.devicePublicKey, 'hex')
    const secBuf = b4a.from(result.deviceSecretKey, 'hex')
    const anKeypair = b4a.toString(pubBuf, 'base64') + b4a.toString(secBuf, 'base64')

    return {
      ...result,
      anKeypair
    }
  }

  /**
   * Close ephemeral swarm and pairing.
   *
   * Drains all swarm connections before destroying. The blind-pairing
   * confirm response (which carries the device bundle to the joining
   * candidate) is enqueued via protomux ch.messages[1].send(...) AFTER
   * the user's onadd handler returns, but the source's
   * `peerConnected` promise resolved synchronously inside that onadd.
   * Without this drain, the daemon's `.finally(() => linker.close())`
   * tears down the noise stream before the bundle frame transmits —
   * candidate's onadd never fires, candidate times out at
   * PAIRING_TIMEOUT_MS thinking nothing happened, while source's
   * "linked: true" reports success.
   *
   * secret-stream.flush() awaits Writable.drained(stream) +
   * rawStream.flush() (UDX socket drain), which is exactly the right
   * semantics — wait for protomux's send to drain into the noise stream
   * and the noise stream's UDX socket to drain to the wire.
   */
  async close () {
    if (this._timeout) {
      clearTimeout(this._timeout)
      this._timeout = null
    }
    if (this._swarm) {
      const conns = (this._externalSwarm || this._swarm).connections
      const flushes = []
      for (const conn of conns) {
        if (typeof conn.flush === 'function') flushes.push(conn.flush())
      }
      await Promise.all(flushes).catch(() => {})
    }
    if (this._pairing) {
      try { await this._pairing.close() } catch {}
      this._pairing = null
    }
    if (this._swarm && !this._externalSwarm) {
      try { await this._swarm.destroy() } catch {}
    }
    this._swarm = null
  }
}

export default DeviceLinker