/**
* Device Linker — Blind-pairing transport for device-chain exchange (Phase 2c).
*
* Security improvement over pear-git v1's DeviceLinker:
* v1 transfers the full BIP39 mnemonic → compromised target leaks root
* v3 transfers a device-scoped keypair + attestation chain → compromised
* target only has its own device key, never the root
*
* Uses blind-pairing over Hyperswarm. Each side creates a short-lived
* Hyperswarm instance for the pairing session (no persistent swarm needed).
*
* Flow:
* Source (has identity root):
* 1. Load own device chain from hyperident vault
* 2. Generate fresh keypair for the target device
* 3. IdentityKey.attestDevice(newPubkey, sourceKp, sourceProof) → newProof
* 4. Create blind-pairing invite, return inviteHex
* 5. On candidate connect: send { deviceSecretKey, deviceProof, rootPubkey, label }
*
* Target (paste inviteHex):
* 1. Connect via blind-pairing invite
* 2. Receive device bundle
* 3. Build anproto keypair from device key
* 4. Sign envelope with chain → IdentityRegistry links device to root
*/
import Hyperswarm from 'hyperswarm'
import BlindPairing from 'blind-pairing'
import crypto from 'hypercore-crypto'
import IdentityKey from 'keet-identity-key'
import Hypercore from 'hypercore'
import b4a from 'b4a'
const PAIRING_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
export class DeviceLinker {
/**
* @param {object} opts
* @param {object} [opts.swarm] — external Hyperswarm (optional; ephemeral created if omitted)
* @param {object} [opts.deviceChain] — { keyPair, proof, metadata } from hyperident vault
* @param {string} [opts.rootPubkeyHex] — identity root public key (hex)
*/
constructor (opts = {}) {
this._externalSwarm = opts.swarm || null
this._swarm = null
this._pairing = null
this._timeout = null
// Source-side fields (set for createInvite)
this._deviceChain = opts.deviceChain || null
this._rootPubkeyHex = opts.rootPubkeyHex || null
}
/**
* Create an invite for device linking (source side).
*
* Caller must pass the source's device chain + root pubkey in the
* constructor, or set them here via opts.
*
* @param {object} [opts]
* @param {string} [opts.label] — label for the new device (e.g. "laptop-pgh")
* @param {object} [opts.deploymentBundle] — optional room bundle for
* mirror setup. When present, the accept-side daemon can boot joined
* to this deployment's rooms. Shape:
* {
* orgs: { bootstrapKey: hex },
* identity: { bootstrapKey: hex },
* releases: { bootstrapKey: hex }, // pgh release-registry
* issueRegistry: { bootstrapKey: hex }, // hypercore + hyperbee
* repoEventLog: { coreKey: hex },
* swarm: { topic: hex }
* }
* For pure user device linking (human laptop, browser), omit this.
* @returns {Promise<{ inviteHex: string, waitForPeer: () => Promise<{ devicePublicKeyHex: string }> }>}
*/
async createInvite (opts = {}) {
const deviceChain = this._deviceChain
if (!deviceChain || !deviceChain.keyPair || !deviceChain.proof) {
throw new Error('createInvite requires source deviceChain (keyPair + proof)')
}
const rootHex = this._rootPubkeyHex
if (!rootHex) {
throw new Error('createInvite requires rootPubkeyHex')
}
const label = opts.label || 'linked-device'
// Generate a fresh Ed25519 keypair for the target device. The
// target uses this for BOTH envelope signing AND as its Autobase
// writer keypair — one key, one identity-registered core.
const newDeviceKp = crypto.keyPair()
// Attest the new device: the source's device key vouches for the
// new device's public key. Chain links newDevice → source → root.
const newProof = IdentityKey.attestDevice(
newDeviceKp.publicKey,
deviceChain.keyPair,
deviceChain.proof
)
// Pre-compute the manifest-derived hypercore key the target's
// Autobase-local core will have when it boots with `keyPair:
// newDeviceKp`. Matching autobase's boot.js + corestore's default
// manifestVersion (1). Sharing this with the caller lets it
// pre-authorize the writer via ADD_WRITER BEFORE the target comes
// online — so the first append the target makes already lands in
// the linearized view.
const autobaseLocalKey = Hypercore.key({
version: 1,
signers: [{ publicKey: newDeviceKp.publicKey }]
})
const autobaseLocalKeyHex = b4a.toString(autobaseLocalKey, 'hex')
// The bundle we'll send over blind-pairing.
const payload = {
deviceSecretKey: b4a.toString(newDeviceKp.secretKey, 'hex'),
devicePublicKey: b4a.toString(newDeviceKp.publicKey, 'hex'),
deviceProof: b4a.toString(newProof, 'base64'),
rootPubkey: rootHex,
label,
// Advertised so the target can confirm which writer key the
// source registered on its behalf (informational; the target
// re-derives identically from deviceSecretKey).
autobaseLocalKey: autobaseLocalKeyHex
}
if (opts.deploymentBundle) {
payload.deploymentBundle = opts.deploymentBundle
}
const bundle = JSON.stringify(payload)
const bundleBuf = b4a.from(bundle, 'utf-8')
// Create blind-pairing invite.
const pairingKey = crypto.keyPair()
const inviteResult = BlindPairing.createInvite(pairingKey.publicKey)
const { invite, publicKey: invitePublicKey, discoveryKey, seed: inviteSeed } = inviteResult
const inviteKeyPair = crypto.keyPair(inviteSeed)
// Ephemeral swarm for this pairing session.
this._swarm = this._externalSwarm || new Hyperswarm()
this._pairing = new BlindPairing(this._swarm)
const peerConnected = new Promise((resolve, reject) => {
this._timeout = setTimeout(() => {
reject(new Error('Timeout waiting for peer'))
}, PAIRING_TIMEOUT_MS)
this._pairing.addMember({
discoveryKey,
onadd: (request) => {
clearTimeout(this._timeout)
this._timeout = null
try {
// Open the request — userData is the joining device's hello.
request.open(invitePublicKey)
// Confirm — send the device bundle as additional data, signed
// with the invite keypair (blind-pairing verifies the sig).
request.confirm({
key: pairingKey.publicKey,
encryptionKey: null,
additional: {
data: bundleBuf,
signature: crypto.sign(bundleBuf, inviteKeyPair.secretKey)
}
})
resolve({
devicePublicKeyHex: b4a.toString(newDeviceKp.publicKey, 'hex'),
autobaseLocalKeyHex
})
} catch (err) {
reject(err)
}
}
})
})
return {
invite,
inviteHex: b4a.toString(invite, 'hex'),
// The target's pre-computed Autobase writer key. Caller MUST
// append an ADD_WRITER op for this key BEFORE the target comes
// online, or the target's first append won't land in the view.
autobaseLocalKeyHex,
newDevicePubkeyHex: b4a.toString(newDeviceKp.publicKey, 'hex'),
waitForPeer: () => peerConnected
}
}
/**
* Join with an invite (target side).
*
* Connects to the source device via blind-pairing, receives the device
* bundle (keypair + chain proof + root). Returns the bundle — caller
* is responsible for storing/registering it.
*
* @param {string} inviteHex — hex-encoded invite from source
* @returns {Promise<{ deviceSecretKey: string, devicePublicKey: string, deviceProof: string, rootPubkey: string, label: string, anKeypair: string }>}
*/
async joinWithInvite (inviteHex) {
if (!inviteHex || typeof inviteHex !== 'string') {
throw new Error('inviteHex is required')
}
const inviteBuf = b4a.from(inviteHex, 'hex')
// Hello payload — just our ephemeral public key so the source knows
// who connected (not required for auth, but useful for logging).
const helloKp = crypto.keyPair()
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'))
}, PAIRING_TIMEOUT_MS)
this._pairing.addCandidate({
invite: inviteBuf,
userData: helloKp.publicKey,
onadd: (auth) => {
clearTimeout(this._timeout)
this._timeout = null
try {
const dataBuf = auth.data
if (!dataBuf || dataBuf.length === 0) {
reject(new Error('No device bundle received from source'))
return
}
const bundle = JSON.parse(b4a.toString(dataBuf, 'utf-8'))
resolve(bundle)
} catch (err) {
reject(err)
}
}
})
})
// Build anproto keypair string from the received device key.
// Format: base64(pubkey 32B) + base64(secretKey 64B) = 132 chars.
const pubBuf = b4a.from(result.devicePublicKey, 'hex')
const secBuf = b4a.from(result.deviceSecretKey, 'hex')
const anKeypair = b4a.toString(pubBuf, 'base64') + b4a.toString(secBuf, 'base64')
return {
...result,
anKeypair
}
}
/**
* Close ephemeral swarm and pairing.
*
* Drains all swarm connections before destroying. The blind-pairing
* confirm response (which carries the device bundle to the joining
* candidate) is enqueued via protomux ch.messages[1].send(...) AFTER
* the user's onadd handler returns, but the source's
* `peerConnected` promise resolved synchronously inside that onadd.
* Without this drain, the daemon's `.finally(() => linker.close())`
* tears down the noise stream before the bundle frame transmits —
* candidate's onadd never fires, candidate times out at
* PAIRING_TIMEOUT_MS thinking nothing happened, while source's
* "linked: true" reports success.
*
* secret-stream.flush() awaits Writable.drained(stream) +
* rawStream.flush() (UDX socket drain), which is exactly the right
* semantics — wait for protomux's send to drain into the noise stream
* and the noise stream's UDX socket to drain to the wire.
*/
async close () {
if (this._timeout) {
clearTimeout(this._timeout)
this._timeout = null
}
if (this._swarm) {
const conns = (this._externalSwarm || this._swarm).connections
const flushes = []
for (const conn of conns) {
if (typeof conn.flush === 'function') flushes.push(conn.flush())
}
await Promise.all(flushes).catch(() => {})
}
if (this._pairing) {
try { await this._pairing.close() } catch {}
this._pairing = null
}
if (this._swarm && !this._externalSwarm) {
try { await this._swarm.destroy() } catch {}
}
this._swarm = null
}
}
export default DeviceLinker