OverviewFilesCommitsIssuesDocsReleasesPeers

hyperident / lib / device-linking-manager.js

323 lines · javascript ref:
/**
 * DeviceLinkingManager - Network-based device linking using hyperswarm
 * Enables secure device sharing and joining over the network
 */

import { randomBytes } from 'node:crypto'
import { IdentityManager } from './identity-manager.js'

let Hyperswarm, b4a, idEncoding

// Lazy load hyperswarm dependencies with graceful fallback
async function loadNetworkDependencies() {
  if (Hyperswarm && b4a && idEncoding) return true
  
  try {
    const hyperswarmModule = await import('hyperswarm')
    const b4aModule = await import('b4a')
    const idEncodingModule = await import('hypercore-id-encoding')
    
    Hyperswarm = hyperswarmModule.default
    b4a = b4aModule.default
    idEncoding = idEncodingModule.default
    
    return true
  } catch (error) {
    return false
  }
}

/**
 * DeviceLinkingManager handles network-based device discovery and linking
 */
export class DeviceLinkingManager {
  constructor() {
    this.swarm = null
    this.isSharing = false
    this.discoveryKey = null
    this.timeout = null
    this.connections = new Set()
  }

  /**
   * Generate sharing topic from identity using profile-based derivation
   * @param {string} profileName - Profile name for sharing (default: 'device-sharing')
   * @returns {Promise<Buffer>} Discovery topic (32 bytes)
   */
  async generateSharingTopic(profileName = 'device-sharing') {
    // Use the existing profile-based topic derivation
    return await IdentityManager.deriveTopicFromProfile(profileName)
  }

  /**
   * Get the sharing profile name (human-readable identifier)
   * @returns {string} Profile name for sharing
   */
  getSharingProfileName() {
    return 'device-sharing'
  }

  /**
   * Start sharing device for network linking
   * @param {number} timeoutMinutes - Timeout in minutes (default: 5)
   * @returns {Promise<string>} Identity public key (hex string) to share with joining devices
   */
  async startSharing(timeoutMinutes = 5) {
    const networkAvailable = await loadNetworkDependencies()
    if (!networkAvailable) {
      throw new Error('Network dependencies not available. Install with: npm install hyperswarm hypercore-id-encoding b4a')
    }

    if (this.isSharing) {
      throw new Error('Already sharing device. Stop current sharing session first.')
    }

    // Check if identity exists
    if (!IdentityManager.identityExists()) {
      throw new Error('Identity not found. Run "hyperident init" first.')
    }

    // Use identity public key as the discovery topic
    const identityPublicKey = await IdentityManager.getPublicKey()
    this.discoveryKey = Buffer.from(identityPublicKey, 'hex')

    // Create hyperswarm instance
    this.swarm = new Hyperswarm()
    this.isSharing = true

    // Set up timeout
    const timeoutMs = timeoutMinutes * 60 * 1000
    this.timeout = setTimeout(() => {
      this.stopSharing()
    }, timeoutMs)

    // Handle connections
    this.swarm.on('connection', (connection, info) => {
      this.handleIncomingConnection(connection, info)
    })

    // Join the discovery key
    const discovery = this.swarm.join(this.discoveryKey, { server: true, client: false })
    await discovery.flushed()

    return identityPublicKey
  }

  /**
   * Join a shared device using the sharing identity's public key
   * @param {string} identityPublicKey - Public key of the sharing identity (hex string)
   * @param {string} deviceName - Name for this device
   * @param {string} passphrase - Passphrase if identity is encrypted
   * @returns {Promise<boolean>} Success status
   */
  async joinDevice(identityPublicKey, deviceName, passphrase = null) {
    const networkAvailable = await loadNetworkDependencies()
    if (!networkAvailable) {
      throw new Error('Network dependencies not available. Install with: npm install hyperswarm hypercore-id-encoding b4a')
    }

    if (this.isSharing) {
      throw new Error('Cannot join while sharing. Stop sharing first.')
    }

    // Check if identity already exists
    if (IdentityManager.identityExists()) {
      throw new Error('Identity already exists. Cannot join device when identity is present.')
    }

    // Use the identity public key directly as the discovery topic
    // This ensures both devices join the same topic
    this.discoveryKey = Buffer.from(identityPublicKey, 'hex')

    // Create hyperswarm instance
    this.swarm = new Hyperswarm()

    return new Promise((resolve, reject) => {
      let connected = false
      const connectionTimeout = setTimeout(() => {
        if (!connected) {
          this.cleanup()
          reject(new Error('Connection timeout. Check the identity public key and try again.'))
        }
      }, 30000) // 30 second timeout

      // Handle connections
      this.swarm.on('connection', async (connection, info) => {
        if (connected) return
        connected = true
        clearTimeout(connectionTimeout)

        try {
          const success = await this.handleJoinConnection(connection, deviceName, passphrase)
          this.cleanup()
          resolve(success)
        } catch (error) {
          this.cleanup()
          reject(error)
        }
      })

      // Join the discovery key
      this.swarm.join(this.discoveryKey, { server: false, client: true })
    })
  }

  /**
   * Handle incoming connection when sharing
   * @param {Object} connection - Hyperswarm connection
   * @param {Object} info - Connection info
   */
  async handleIncomingConnection(connection, info) {
    this.connections.add(connection)

    try {
      // Send device bundle to joining device
      const bundle = await IdentityManager.exportDeviceBundle()
      const bundleData = JSON.stringify(bundle)
      
      // Send bundle size first
      const sizeBuffer = Buffer.alloc(4)
      sizeBuffer.writeUInt32BE(bundleData.length, 0)
      connection.write(sizeBuffer)
      
      // Send bundle data
      connection.write(Buffer.from(bundleData, 'utf8'))
      
      console.log('📤 Device bundle sent to joining device')
      
      // Close connection after sending
      setTimeout(() => {
        connection.end()
      }, 1000)
      
    } catch (error) {
      console.error('❌ Error sending device bundle:', error.message)
      connection.end()
    }

    connection.on('close', () => {
      this.connections.delete(connection)
    })

    connection.on('error', (error) => {
      console.error('Connection error:', error.message)
      this.connections.delete(connection)
    })
  }

  /**
   * Handle connection when joining a device
   * @param {Object} connection - Hyperswarm connection
   * @param {string} deviceName - Name for this device
   * @param {string} passphrase - Passphrase if needed
   * @returns {Promise<boolean>} Success status
   */
  async handleJoinConnection(connection, deviceName, passphrase) {
    return new Promise((resolve, reject) => {
      let bundleSize = null
      let receivedData = Buffer.alloc(0)
      let expectingSize = true

      connection.on('data', async (data) => {
        receivedData = Buffer.concat([receivedData, data])

        try {
          if (expectingSize && receivedData.length >= 4) {
            // Read bundle size
            bundleSize = receivedData.readUInt32BE(0)
            receivedData = receivedData.slice(4)
            expectingSize = false
          }

          if (!expectingSize && bundleSize && receivedData.length >= bundleSize) {
            // Received complete bundle
            const bundleData = receivedData.slice(0, bundleSize).toString('utf8')
            const bundle = JSON.parse(bundleData)

            // Import the device bundle
            await IdentityManager.importDeviceBundle(bundle, deviceName, passphrase)
            
            console.log('✅ Device bundle imported successfully')
            resolve(true)
          }
        } catch (error) {
          reject(error)
        }
      })

      connection.on('error', (error) => {
        reject(new Error(`Connection error: ${error.message}`))
      })

      connection.on('close', () => {
        if (bundleSize === null) {
          reject(new Error('Connection closed before receiving device bundle'))
        }
      })
    })
  }

  /**
   * Stop sharing device
   */
  async stopSharing() {
    if (!this.isSharing) return

    this.isSharing = false

    if (this.timeout) {
      clearTimeout(this.timeout)
      this.timeout = null
    }

    await this.cleanup()
  }

  /**
   * Clean up resources
   */
  async cleanup() {
    // Close all connections
    for (const connection of this.connections) {
      try {
        connection.end()
      } catch (error) {
        // Ignore cleanup errors
      }
    }
    this.connections.clear()

    // Close swarm
    if (this.swarm) {
      try {
        await this.swarm.destroy()
      } catch (error) {
        // Ignore cleanup errors
      }
      this.swarm = null
    }

    this.discoveryKey = null
  }

  /**
   * Get sharing status
   * @returns {Object} Status information
   */
  getStatus() {
    return {
      isSharing: this.isSharing,
      connections: this.connections.size,
      discoveryKey: this.discoveryKey ? this.discoveryKey.toString('hex') : null
    }
  }

  /**
   * Check if network dependencies are available
   * @returns {Promise<boolean>} Availability status
   */
  static async isNetworkAvailable() {
    return await loadNetworkDependencies()
  }
}