OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / repo-link / repo-linker.js

181 lines · javascript ref:
/**
 * Repo Linker — Blind-pairing transport for bootstrap key exchange
 *
 * Uses blind-pairing over Hyperswarm to securely exchange Autobase
 * bootstrap keys between pear-git servers. Follows DeviceLinker pattern
 * exactly — owner creates invite, joiner connects with invite.
 *
 * Flow:
 *   Owner: creates invite, sends { bootstrapKey, repoName, role }
 *   Joiner: connects with invite, sends { userId, serverKey, identityKey, proof }
 *   After pairing: owner appends add-writer to Autobase
 *
 * This enables multi-server collaborative repos without sharing
 * raw Autobase keys out-of-band.
 */

import Hyperswarm from 'hyperswarm'
import BlindPairing from 'blind-pairing'
import crypto from 'hypercore-crypto'

export class RepoLinker {
  constructor ({ swarm, bootstrap } = {}) {
    this._externalSwarm = swarm || null
    this._bootstrap = bootstrap || null
    this._swarm = null
    this._pairing = null
    this._timeout = null
  }

  /**
   * Create an invite for repo linking (owner side)
   *
   * The owner has an existing LinkedRepo and wants to add a writer.
   * The payload is sent to the joiner after they connect.
   *
   * @param {Object} payload - Data to send to joiner
   * @param {string} payload.bootstrapKey - Autobase bootstrap key (hex)
   * @param {string} payload.repoName - Repository name
   * @param {string} payload.role - Role to grant ('writer' or 'mirror')
   * @returns {Promise<{ invite: Buffer, inviteHex: string, waitForPeer: function }>}
   */
  async createInvite (payload) {
    const payloadBuf = Buffer.from(JSON.stringify(payload), 'utf-8')

    // Generate key for discovery
    const pairingKey = crypto.keyPair()

    // Create invite
    const inviteResult = BlindPairing.createInvite(pairingKey.publicKey)
    const { invite, publicKey: invitePublicKey, seed: inviteSeed } = inviteResult

    // Derive invite's full keypair from seed (needed for signing)
    const inviteKeyPair = crypto.keyPair(inviteSeed)

    // Create ephemeral swarm (use bootstrap for testnet support)
    this._swarm = this._externalSwarm || new Hyperswarm(this._bootstrap ? { bootstrap: this._bootstrap } : {})
    this._pairing = new BlindPairing(this._swarm)

    const peerConnected = new Promise((resolve, reject) => {
      this._timeout = setTimeout(() => {
        reject(new Error('Timeout waiting for peer'))
      }, 5 * 60 * 1000) // 5 minute timeout

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

          try {
            // Open the request — userData contains joiner's info
            const joinerDataBuf = request.open(invitePublicKey)

            let joinerInfo = null
            if (joinerDataBuf && joinerDataBuf.length > 0) {
              try {
                joinerInfo = JSON.parse(joinerDataBuf.toString('utf-8'))
              } catch {
                joinerInfo = { raw: joinerDataBuf.toString('hex') }
              }
            }

            // Confirm with payload (bootstrapKey + repoName + role)
            request.confirm({
              key: pairingKey.publicKey,
              encryptionKey: null,
              additional: {
                data: payloadBuf,
                signature: crypto.sign(payloadBuf, inviteKeyPair.secretKey)
              }
            })

            resolve(joinerInfo)
          } catch (err) {
            reject(err)
          }
        }
      })
    })

    return {
      invite,
      inviteHex: invite.toString('hex'),
      waitForPeer: () => peerConnected
    }
  }

  /**
   * Join with an invite (joiner side)
   *
   * The joiner connects to the owner, sends their identity info,
   * and receives the Autobase bootstrap key.
   *
   * @param {string} inviteHex - Hex-encoded invite from owner
   * @param {Object} joinerInfo - Joiner's identity info to send to owner
   * @param {string} joinerInfo.userId - User ID
   * @param {string} joinerInfo.serverKey - Server's Noise key (hex)
   * @param {string} [joinerInfo.identityKey] - Keet identity key (hex)
   * @param {string} [joinerInfo.proof] - Identity proof
   * @returns {Promise<{ bootstrapKey: string, repoName: string, role: string }>}
   */
  async joinWithInvite (inviteHex, joinerInfo) {
    const inviteBuf = Buffer.from(inviteHex, 'hex')
    const joinerDataBuf = Buffer.from(JSON.stringify(joinerInfo), 'utf-8')

    // Create ephemeral swarm (use bootstrap for testnet support)
    this._swarm = this._externalSwarm || new Hyperswarm(this._bootstrap ? { bootstrap: this._bootstrap } : {})
    this._pairing = new BlindPairing(this._swarm)

    const result = await new Promise((resolve, reject) => {
      this._timeout = setTimeout(() => {
        reject(new Error('Timeout waiting for owner'))
      }, 5 * 60 * 1000)

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

          try {
            const dataBuf = auth.data
            if (!dataBuf) {
              reject(new Error('No payload received from owner'))
              return
            }

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

    return result
  }

  /**
   * Close ephemeral swarm and pairing
   */
  async close () {
    if (this._timeout) {
      clearTimeout(this._timeout)
      this._timeout = null
    }
    if (this._pairing) {
      await this._pairing.close()
      this._pairing = null
    }
    if (this._swarm && !this._externalSwarm) {
      await this._swarm.destroy()
    }
    this._swarm = null
  }
}

export default RepoLinker