OverviewFilesCommitsIssuesDocsReleasesPeers

hyperident-gateway / lib / gateway-identity.js

336 lines · javascript ref:
/**
 * GatewayIdentity - Integrates with hyperident for service identity management
 *
 * Responsibilities:
 * - Derive deterministic Hyperswarm topics from hyperident profiles
 * - Assign virtual IPs via hashing
 * - Manage service registry
 */

import crypto from 'hypercore-crypto'
import fs from 'bare-fs/promises'
import os from 'bare-os'
import path from 'bare-path'

export class GatewayIdentity {
  constructor(config = {}) {
    this.hyperidentPath = config.hyperidentPath || path.join(os.homedir(), '.hyperident')
    this.subnet = config.subnet || '10.22.0.0/16'
    this.ipAssignmentStrategy = config.ipAssignmentStrategy || 'hash'
    this.staticMappings = config.staticMappings || {}
    this.gatewayIP = config.gatewayIP || null // Store gateway's own IP

    // Parse subnet
    const [network, cidr] = this.subnet.split('/')
    this.networkPrefix = network.split('.').slice(0, 2).join('.') // e.g., '10.22'
    this.cidr = parseInt(cidr)

    // Service registry: virtualIP → service info
    this.services = new Map()

    // Topic cache: profileName → topic
    this.topicCache = new Map()
  }

  /**
   * Ensure ~/.hyperident/ exists with a valid identity.
   * Auto-generates one if missing using hypercore-crypto.
   */
  async ensureIdentity () {
    const identityPubPath = path.join(this.hyperidentPath, 'identity.pub')

    try {
      await fs.access(identityPubPath)
      return // Identity exists
    } catch {}

    console.log('[Gateway] No hyperident identity found — generating...')

    // Generate identity keypair (root identity for this device)
    const identityKp = crypto.keyPair()

    // Generate device keypair (unique to this machine)
    const deviceKp = crypto.keyPair()

    // Create directory with restricted permissions
    await fs.mkdir(this.hyperidentPath, { recursive: true, mode: 0o700 })

    // Write identity files
    await fs.writeFile(
      identityPubPath,
      identityKp.publicKey.toString('hex'),
      { mode: 0o644 }
    )
    await fs.writeFile(
      path.join(this.hyperidentPath, 'identity'),
      identityKp.secretKey.toString('hex'),
      { mode: 0o600 }
    )
    await fs.writeFile(
      path.join(this.hyperidentPath, 'device.pub'),
      deviceKp.publicKey.toString('hex'),
      { mode: 0o644 }
    )
    await fs.writeFile(
      path.join(this.hyperidentPath, 'device.key'),
      deviceKp.secretKey.toString('hex'),
      { mode: 0o600 }
    )

    // Device metadata
    const hostname = os.hostname()
    await fs.writeFile(
      path.join(this.hyperidentPath, 'device.json'),
      JSON.stringify({
        hostname,
        created: new Date().toISOString(),
        devicePublicKey: deviceKp.publicKey.toString('hex'),
        identityPublicKey: identityKp.publicKey.toString('hex')
      }, null, 2),
      { mode: 0o644 }
    )

    // Default profile based on hostname
    const profileAddress = crypto.hash(
      Buffer.concat([identityKp.publicKey, Buffer.from(hostname)])
    ).toString('hex')

    await fs.writeFile(
      path.join(this.hyperidentPath, 'profiles.json'),
      JSON.stringify({
        version: '1.0.0',
        profiles: {
          [hostname]: {
            name: hostname,
            description: 'Auto-generated by hyperident-gateway',
            created: new Date().toISOString(),
            lastModified: new Date().toISOString(),
            address: profileAddress
          }
        }
      }, null, 2),
      { mode: 0o644 }
    )

    console.log(`[Gateway] Identity created:`)
    console.log(`  Identity: ${identityKp.publicKey.toString('hex').substring(0, 16)}...`)
    console.log(`  Device:   ${deviceKp.publicKey.toString('hex').substring(0, 16)}...`)
    console.log(`  Profile:  ${hostname}`)
    console.log(`  Path:     ${this.hyperidentPath}`)
  }

  /**
   * Load hyperident identity (similar to IdentityManager.loadIdentity)
   * For now, we'll use a simplified version that just reads profiles
   */
  async loadProfiles() {
    // Ensure identity exists before trying to read
    await this.ensureIdentity()

    try {
      const profilesPath = path.join(this.hyperidentPath, 'profiles.json')
      const data = await fs.readFile(profilesPath, 'utf8')
      const profilesData = JSON.parse(data)
      return profilesData.profiles || {}
    } catch (error) {
      throw new Error(`Failed to load hyperident profiles: ${error.message}`)
    }
  }

  /**
   * Derive Hyperswarm topic from hyperident profile
   * This mimics what hyperident's IdentityManager.deriveTopicFromProfile() does
   *
   * For now, we'll use a simplified approach:
   * - Hash the profile name with a consistent algorithm
   * - Return 32-byte topic
   *
   * TODO: Integrate directly with hyperident's IdentityManager for true derivation
   */
  async deriveServiceTopic(profileName) {
    // Check cache first
    if (this.topicCache.has(profileName)) {
      return this.topicCache.get(profileName)
    }

    // For MVP: Simple hash-based topic derivation
    // This won't match hyperident's keet-identity-key derivation exactly
    // but will be deterministic and unique
    const profileHash = crypto.hash(Buffer.from(profileName, 'utf8'))
    const topic = profileHash.subarray(0, 32) // 32-byte topic

    this.topicCache.set(profileName, topic)
    return topic
  }

  /**
   * Assign virtual IP from topic
   * Uses hash-based assignment for determinism
   */
  assignIP(topic) {
    // Check static mappings first
    // (Will implement after we have service name)

    const hash = crypto.hash(topic)

    if (this.cidr === 16) {
      // /16 subnet: 10.22.X.X
      const octet3 = hash[0]
      const octet4 = hash[1]
      return `${this.networkPrefix}.${octet3}.${octet4}`
    } else if (this.cidr === 24) {
      // /24 subnet: 10.22.0.X
      const octet4 = hash[0] % 254 + 1 // Avoid .0 and .255
      return `${this.networkPrefix}.0.${octet4}`
    } else {
      throw new Error(`Unsupported subnet CIDR: /${this.cidr}. Use /16 or /24`)
    }
  }

  /**
   * Check for IP collision
   */
  hasCollision(ip) {
    return this.services.has(ip)
  }

  /**
   * Register a service
   * @param {Object} serviceConfig - { name, profile, port, transport }
   * @returns {Object} - { name, ip, topic, port, transport }
   */
  async registerService(serviceConfig) {
    const { name, profile, port, transport = 'tcp' } = serviceConfig

    if (!name || !profile || !port) {
      throw new Error('Service registration requires: name, profile, port')
    }

    // Check if hyperident profile exists
    const profiles = await this.loadProfiles()
    if (!profiles[profile]) {
      throw new Error(`Hyperident profile '${profile}' not found. Create it with: hyperident create-profile ${profile}`)
    }

    // Derive topic from profile
    const topic = await this.deriveServiceTopic(profile)

    // Assign IP (with static override support)
    let ip
    if (this.staticMappings[name]) {
      ip = this.staticMappings[name]
      console.log(`📌 Using static IP mapping for ${name}: ${ip}`)
    } else {
      ip = this.assignIP(topic)
    }

    // Check for collision
    if (this.hasCollision(ip)) {
      const existing = this.services.get(ip)
      throw new Error(`IP collision: ${ip} already assigned to service '${existing.name}'. Use static mapping or different profile.`)
    }

    // Store service
    const service = {
      name,
      profile,
      ip,
      topic: topic.toString('hex'),
      topicBuffer: topic,
      port,
      transport,
      connections: [], // Will be populated by HyperswarmRouter
      registered: new Date().toISOString()
    }

    this.services.set(ip, service)

    console.log(`✅ Registered service: ${name}`)
    console.log(`   Profile: ${profile}`)
    console.log(`   Virtual IP: ${ip}:${port}`)
    console.log(`   Topic: ${topic.toString('hex').substring(0, 16)}...`)

    return service
  }

  /**
   * Lookup service by virtual IP
   */
  lookup(virtualIP) {
    return this.services.get(virtualIP)
  }

  /**
   * List all registered services
   */
  listServices() {
    return Array.from(this.services.values())
  }

  /**
   * Get all IPs owned by this gateway
   * Used for announcement protocol in P2P VPN
   * @returns {Array<string>} - Array of IP addresses
   */
  getOwnedIPs() {
    const ips = []

    // Only announce gateway's own IP
    // Service IPs are handled locally and not routed via P2P
    if (this.gatewayIP) {
      ips.push(this.gatewayIP)
    }

    return ips
  }

  /**
   * Get service by name
   */
  getServiceByName(name) {
    for (const service of this.services.values()) {
      if (service.name === name) {
        return service
      }
    }
    return null
  }

  /**
   * Update service connection
   */
  updateConnection(virtualIP, connection) {
    const service = this.services.get(virtualIP)
    if (service) {
      service.connections.push(connection)
    }
  }

  /**
   * Remove service connection
   */
  removeConnection(virtualIP, connection) {
    const service = this.services.get(virtualIP)
    if (service) {
      service.connections = service.connections.filter(c => c !== connection)
    }
  }

  /**
   * Export service mappings for debugging/monitoring
   */
  exportMappings() {
    const mappings = {}
    for (const [ip, service] of this.services.entries()) {
      mappings[ip] = {
        name: service.name,
        profile: service.profile,
        topic: service.topic,
        port: service.port,
        activeConnections: service.connections.length
      }
    }
    return mappings
  }
}