/**
* 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()
}
}