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