OverviewFilesCommitsIssuesDocsReleasesPeers

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

183 lines · javascript ref:
/**
 * Device Linker — Blind-pairing transport for mnemonic exchange
 *
 * Uses blind-pairing over Hyperswarm to securely transfer
 * a Keet mnemonic between devices. Each device gets its own
 * proof chain attesting to the same root identity.
 *
 * Flow:
 *   Source device (link-device): creates invite, waits for candidate
 *   Target device (join-device): connects with invite, receives mnemonic
 */

import Hyperswarm from 'hyperswarm'
import BlindPairing from 'blind-pairing'
import crypto from 'hypercore-crypto'
import { IdentityManager } from './identity-manager.js'

export class DeviceLinker {
  constructor ({ storagePath, swarm }) {
    this.storagePath = storagePath
    this.identityManager = new IdentityManager({ storagePath })
    this._externalSwarm = swarm || null
    this._swarm = null
    this._pairing = null
    this._timeout = null
  }

  /**
   * Create an invite for device linking (source device)
   *
   * Decrypts mnemonic from vault, creates blind-pairing invite,
   * waits for a candidate to connect, then transfers the mnemonic.
   *
   * @param {Buffer} password - Vault password
   * @returns {Promise<{ invite: Buffer, inviteHex: string, waitForPeer: function }>}
   */
  async createInvite (password) {
    // Decrypt mnemonic
    const mnemonic = await this.identityManager.exportMnemonic(password)
    const mnemonicBuf = Buffer.from(mnemonic, 'utf-8')

    // Generate key for discovery (becomes discoveryKey for swarm pairing)
    const pairingKey = crypto.keyPair()

    // Create invite — returns seed-derived keypair for crypto operations
    const inviteResult = BlindPairing.createInvite(pairingKey.publicKey)
    const { invite, publicKey: invitePublicKey, discoveryKey, seed: inviteSeed } = inviteResult

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

    // Create ephemeral swarm (or use provided one)
    this._swarm = this._externalSwarm || new Hyperswarm()
    this._pairing = new BlindPairing(this._swarm)

    // Set up member (server side) — wait for candidate
    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,
        onadd: (request) => {
          clearTimeout(this._timeout)
          this._timeout = null

          try {
            // Open the request with the invite's seed-derived publicKey
            const userData = request.open(invitePublicKey)

            // userData contains the joining device's public key
            const joiningDeviceKey = userData

            // Confirm — key must match discoveryKey, sign additional with invite keypair
            request.confirm({
              key: pairingKey.publicKey,
              encryptionKey: null,
              additional: {
                data: mnemonicBuf,
                signature: crypto.sign(mnemonicBuf, inviteKeyPair.secretKey)
              }
            })

            resolve({
              devicePublicKey: joiningDeviceKey ? joiningDeviceKey.toString('hex') : null
            })
          } catch (err) {
            reject(err)
          }
        }
      })
    })

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

  /**
   * Join with an invite (target device)
   *
   * Connects to source device via invite, receives mnemonic,
   * creates local vault + proof chain.
   *
   * @param {string} inviteHex - Hex-encoded invite from source device
   * @param {Buffer} password - Password for local vault encryption
   * @returns {Promise<{ identityPublicKey: string }>}
   */
  async joinWithInvite (inviteHex, password) {
    const inviteBuf = Buffer.from(inviteHex, 'hex')

    // Read local device public key
    const status = this.identityManager.status()
    if (!status.hasKeypair) {
      throw new Error('No server identity found — start the server at least once first')
    }
    const devicePublicKey = Buffer.from(status.devicePublicKey, 'hex')

    // Create ephemeral swarm (or use provided one)
    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'))
      }, 5 * 60 * 1000)

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

          try {
            // auth.data contains the mnemonic (from additional.data)
            const mnemonicBuf = auth.data
            if (!mnemonicBuf) {
              reject(new Error('No mnemonic received from source device'))
              return
            }

            resolve({ mnemonic: mnemonicBuf.toString('utf-8') })
          } catch (err) {
            reject(err)
          }
        }
      })
    })

    // Setup from received mnemonic
    const { identityPublicKey } = await this.identityManager.setupFromMnemonic(
      result.mnemonic,
      password
    )

    return { identityPublicKey }
  }

  /**
   * 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 DeviceLinker