OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / lib / pairing.js

181 lines · javascript ref:
// Device pairing — moving a device credential to a second machine, over blind-pairing.
//
// ── What actually travels ─────────────────────────────────────────────────────────────────────
//
// A freshly minted device credential: `{ keyPair, proof }`, where the source's own device key
// attested the new one. NEVER the mnemonic.
//
//     v1 of this idea shipped the BIP39 mnemonic → a compromised target leaked the root
//     v3 ships a device-scoped keypair + chain → a compromised target has only its own device key
//
// That is pear-git-v3's `lib/device-linker.js` verbatim, and it is why linking a machine does not
// open the vault: `attestDevice(publicKey, parent, proof)` signs with the PARENT DEVICE's keypair.
//
// ── What the target does with it ──────────────────────────────────────────────────────────────
//
// Exactly what `favela link` does with a vault-minted credential: save it, open the feed, bind its
// own writer core to the device with `attestData`, and admit itself. Nothing about the receiving
// path is pairing-specific — the credential is the credential, however it arrived.
//
// ⚠ So the target needs NO pre-computation and no second round trip. pgv3 pre-computes the target's
// writer key (`Hypercore.key({version:1, signers:[{publicKey}]})` — measured, that is exactly what
// autobee derives when handed a keyPair) so it can ADD_WRITER before the target boots. We do not
// need that: our target binds its own writer key locally, because binding takes the device key, not
// the root.
//
// ── ⛔ The trap ───────────────────────────────────────────────────────────────────────────────
//
// `close()` MUST drain the swarm connections before destroying them. blind-pairing enqueues the
// confirm frame — the one carrying the bundle — AFTER the source's `onadd` handler returns, but the
// source's "peer connected" promise resolves inside that handler. Tear the stream down on the next
// line and the frame never transmits: the candidate waits out its whole timeout believing nothing
// happened, while the source reports success. One-sided, silent, and slow to diagnose. pgv3 hit
// this and wrote it down; the drain below is that fix.

import Hyperswarm from 'hyperswarm'
import BlindPairing from 'blind-pairing'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import { mintForDevice } from './device.js'

const PAIRING_TIMEOUT_MS = 5 * 60 * 1000

export class Pairing {
  constructor (opts = {}) {
    this._externalSwarm = opts.swarm || null
    this._swarm = null
    this._pairing = null
    this._timeout = null
  }

  /**
   * Offer a device credential to whoever redeems the invite (source side).
   *
   * `cred` is THIS machine's credential; the new one is minted from it, so the mnemonic is never
   * touched and the root never leaves.
   *
   * Returns `{ invite, joined }` — `invite` is the hex string a human carries to the other machine,
   * `joined` resolves when a peer has taken it.
   */
  async offer (cred, opts = {}) {
    if (!cred || !cred.keyPair || !cred.proof) {
      throw new Error('offer requires this device\'s credential { keyPair, proof } — link this machine first')
    }

    const target = mintForDevice(cred)
    const bundle = b4a.from(JSON.stringify({
      publicKey: b4a.toString(target.keyPair.publicKey, 'hex'),
      secretKey: b4a.toString(target.keyPair.secretKey, 'hex'),
      proof: target.proof,
      // ⛔ The feed key travels too. Being someone's device and writing to their feed are different
      // facts: the chain proves the first, and only this carries the second. Without it the target
      // founds a feed of its own — same owner, different log, which is not what "my other phone"
      // means.
      feedKey: opts.feedKey || null,
      label: opts.label || 'linked-device'
    }), 'utf-8')

    const pairingKey = crypto.keyPair()
    const { invite, publicKey: invitePublicKey, discoveryKey, seed } = BlindPairing.createInvite(pairingKey.publicKey)
    const inviteKeyPair = crypto.keyPair(seed)

    this._swarm = this._externalSwarm || new Hyperswarm(opts.swarmOpts || {})
    this._pairing = new BlindPairing(this._swarm)

    const joined = new Promise((resolve, reject) => {
      this._timeout = setTimeout(() => reject(new Error('nobody took the invite')), opts.timeoutMs || PAIRING_TIMEOUT_MS)
      this._pairing.addMember({
        discoveryKey,
        onadd: (request) => {
          clearTimeout(this._timeout)
          this._timeout = null
          try {
            request.open(invitePublicKey)
            // The bundle rides the confirm as `additional.data`, signed with the invite keypair so
            // blind-pairing can verify it came from whoever minted the invite.
            request.confirm({
              key: pairingKey.publicKey,
              encryptionKey: null,
              additional: { data: bundle, signature: crypto.sign(bundle, inviteKeyPair.secretKey) }
            })
            resolve({ devicePublicKey: b4a.toString(target.keyPair.publicKey, 'hex') })
          } catch (err) {
            reject(err)
          }
        }
      })
    })

    return { invite: b4a.toString(invite, 'hex'), joined }
  }

  /**
   * Redeem an invite and come back with a device credential (target side).
   *
   * Returns `{ keyPair, proof }` in the same shape `loadCredential` produces, so the caller hands
   * it straight to `node.link()` — the receiving path is not pairing-specific.
   */
  async accept (inviteHex, opts = {}) {
    if (typeof inviteHex !== 'string' || !inviteHex) throw new Error('accept requires an invite')

    let inviteBuf
    try { inviteBuf = b4a.from(inviteHex.trim(), 'hex') } catch { throw new Error('malformed invite') }
    if (!inviteBuf.length) throw new Error('malformed invite')

    const hello = crypto.keyPair()
    this._swarm = this._externalSwarm || new Hyperswarm(opts.swarmOpts || {})
    this._pairing = new BlindPairing(this._swarm)

    const bundle = await new Promise((resolve, reject) => {
      this._timeout = setTimeout(() => reject(new Error('the other device did not answer')), opts.timeoutMs || PAIRING_TIMEOUT_MS)
      this._pairing.addCandidate({
        invite: inviteBuf,
        userData: hello.publicKey,
        onadd: (auth) => {
          clearTimeout(this._timeout)
          this._timeout = null
          try {
            if (!auth.data || auth.data.length === 0) return reject(new Error('the invite carried no credential'))
            resolve(JSON.parse(b4a.toString(auth.data, 'utf-8')))
          } catch (err) {
            reject(err)
          }
        }
      })
    })

    if (!bundle.publicKey || !bundle.secretKey || !bundle.proof) {
      throw new Error('the credential that arrived is incomplete')
    }

    return {
      keyPair: {
        publicKey: b4a.from(bundle.publicKey, 'hex'),
        secretKey: b4a.from(bundle.secretKey, 'hex')
      },
      proof: bundle.proof,
      feedKey: bundle.feedKey || null,
      label: bundle.label || null
    }
  }

  /**
   * ⛔ Drain BEFORE destroy. See the header — without this the confirm frame carrying the bundle
   * never leaves, and the failure is one-sided and silent.
   */
  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 Pairing