OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / p2p / identity / server-linker.js

163 lines · javascript ref:
/**
 * Server Linker — Blind-pairing transport for server identity transfer
 *
 * Transfers server mnemonic + deploy metadata from an existing pear-git
 * server to a new one. Both servers end up with the same identity,
 * so they produce identical punch:// and pear:// URLs.
 *
 * Flow:
 *   Source server (create-invite): exports mnemonic, waits for peer
 *   Target server (join): receives mnemonic + metadata, reconstructs identity
 */

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

export class ServerLinker {
  constructor ({ serverIdentity, storagePath, swarm }) {
    this.serverIdentity = serverIdentity || null
    this.storagePath = storagePath || null
    this._externalSwarm = swarm || null
    this._swarm = null
    this._pairing = null
    this._timeout = null
  }

  /**
   * Create an invite for server linking (source server)
   *
   * @param {Object} metadata
   * @param {string[]} metadata.repoList - List of repo names
   * @param {Object} metadata.deployKeys - Map of repo name -> pear:// URL
   * @returns {Promise<{ invite: Buffer, inviteHex: string, waitForPeer: function }>}
   */
  async createInvite (metadata = {}) {
    if (!this.serverIdentity) {
      throw new Error('Server identity required to create invite')
    }

    const mnemonic = this.serverIdentity.toMnemonic()

    const payload = {
      mnemonic,
      serverKey: this.serverIdentity.getPublicKeyHex(),
      repoList: metadata.repoList || [],
      deployKeys: metadata.deployKeys || {},
      deployRegistryKey: metadata.deployRegistryKey || null,
      createdAt: Date.now()
    }
    const payloadBuf = Buffer.from(JSON.stringify(payload), 'utf-8')

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

    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 target server'))
      }, 10 * 60 * 1000)

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

          try {
            const userData = request.open(invitePublicKey)

            request.confirm({
              key: pairingKey.publicKey,
              encryptionKey: null,
              additional: {
                data: payloadBuf,
                signature: crypto.sign(payloadBuf, inviteKeyPair.secretKey)
              }
            })

            resolve({
              targetServerKey: userData ? userData.toString('hex') : null
            })
          } catch (err) {
            reject(err)
          }
        }
      })
    })

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

  /**
   * Join with an invite (target server)
   *
   * @param {string} inviteHex - Hex-encoded invite from source server
   * @returns {Promise<{ mnemonic, serverKey, repoList, deployKeys }>}
   */
  async joinWithInvite (inviteHex) {
    const inviteBuf = Buffer.from(inviteHex, 'hex')

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

    const userData = this.storagePath
      ? Buffer.from(this.storagePath, 'utf-8')
      : Buffer.alloc(0)

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

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

          try {
            if (!auth.data) {
              reject(new Error('No payload received from source server'))
              return
            }

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

    return result
  }

  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 ServerLinker