/**
* pgh device linker — Phase 2c.
*
* Links a second device (target) to an existing identity (source) via
* blind-pairing over Hyperswarm. Transfers a DEVICE-SCOPED keypair +
* attestation chain, not the root mnemonic. A compromised target leaks
* that one device; the root stays on source.
*
* Wire format matches pear-git-v3/lib/device-linker.js so either side
* can interop: the payload is JSON over a signed blind-pairing
* `additional` field. Signature is checked by blind-pairing itself
* against the invite keypair.
*/
import BlindPairing from 'blind-pairing'
import b4a from 'b4a'
import crypto from 'hypercore-crypto'
/**
* Create a link invite (source side).
*
* @param {object} opts
* @param {object} opts.swarm — Hyperswarm instance (owned by caller)
* @param {object} opts.sourceIdentity — from pgh/lib/identity.js
* @param {string} [opts.label] — human label for the new device
* @param {number} [opts.timeout] — reject if no candidate within this ms (default 5 min)
* @returns {Promise<{ invite: string, linked: Promise<object>, close: () => Promise<void> }>}
*/
export async function createInvite (opts) {
const swarm = opts.swarm
if (!swarm) throw new Error('createInvite requires opts.swarm')
const src = opts.sourceIdentity
if (!src) throw new Error('createInvite requires opts.sourceIdentity')
if (!src.chain) throw new Error('source identity has no chain — cannot attest new device')
if (!src.publicKey || !src.secretKey) throw new Error('source identity missing device keypair')
if (!src.rootPubkeyHex) throw new Error('source identity missing rootPubkeyHex')
const label = opts.label || null
const timeoutMs = opts.timeout || 5 * 60 * 1000
const { default: IdentityKey } = await import('keet-identity-key')
// Fresh device keypair for the target — the whole point of this
// transport is we never share the source's own device key.
const newDeviceKp = crypto.keyPair()
// Extend the chain: source's device attests the new device's pubkey.
const sourceProof = b4a.from(src.chain, 'base64')
const extendedProof = IdentityKey.attestDevice(
newDeviceKp.publicKey,
{ publicKey: src.publicKey, secretKey: src.secretKey },
sourceProof
)
const payload = {
version: 1,
label,
rootPubkeyHex: src.rootPubkeyHex,
devicePublicKeyHex: b4a.toString(newDeviceKp.publicKey, 'hex'),
deviceSecretKeyHex: b4a.toString(newDeviceKp.secretKey, 'hex'),
chainB64: b4a.toString(extendedProof, 'base64'),
createdAt: Date.now()
}
const bundleBuf = b4a.from(JSON.stringify(payload), 'utf-8')
// Pairing key: fresh keypair whose PUBLIC KEY is the argument to
// createInvite. The corresponding invite seed is returned in the
// invite so the candidate can derive the same keypair.
const pairingKey = crypto.keyPair()
const inviteResult = BlindPairing.createInvite(pairingKey.publicKey)
const { invite, publicKey: invitePublicKey, discoveryKey, seed: inviteSeed } = inviteResult
const inviteKeyPair = crypto.keyPair(inviteSeed)
const bp = new BlindPairing(swarm)
let resolveLinked, rejectLinked
const linked = new Promise((resolve, reject) => {
resolveLinked = resolve
rejectLinked = reject
})
const timer = setTimeout(() => {
rejectLinked(new Error('no candidate connected within ' + timeoutMs + 'ms'))
}, timeoutMs)
bp.addMember({
discoveryKey,
async onadd (request) {
clearTimeout(timer)
try {
request.open(invitePublicKey)
request.confirm({
key: pairingKey.publicKey,
encryptionKey: null,
additional: {
data: bundleBuf,
signature: crypto.sign(bundleBuf, inviteKeyPair.secretKey)
}
})
resolveLinked({
newDevicePubkeyHex: payload.devicePublicKeyHex,
label
})
} catch (err) {
try { request.deny({ status: 1 }) } catch {}
rejectLinked(err)
}
}
})
return {
invite: b4a.toString(invite, 'hex'),
linked,
close: async () => {
clearTimeout(timer)
try { await bp.close() } catch {}
}
}
}
/**
* Join with an invite (target side).
*
* @param {object} opts
* @param {object} opts.swarm — Hyperswarm instance (owned by caller)
* @param {string} opts.invite — hex string from source's createInvite
* @param {number} [opts.timeout=5min] — reject if no payload within this
* @returns {Promise<object>} payload delivered by source (version:1 shape)
*/
export async function joinInvite (opts) {
const swarm = opts.swarm
if (!swarm) throw new Error('joinInvite requires opts.swarm')
if (!opts.invite) throw new Error('joinInvite requires opts.invite')
const timeoutMs = opts.timeout || 5 * 60 * 1000
const inviteBuf = b4a.from(opts.invite, 'hex')
// Ephemeral hello — sent to source in userData. Not used for auth
// (blind-pairing does that), just gives the source something to log.
const helloKp = crypto.keyPair()
const bp = new BlindPairing(swarm)
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('device link timed out after ' + timeoutMs + 'ms'))
try { bp.close() } catch {}
}, timeoutMs)
bp.addCandidate({
invite: inviteBuf,
userData: helloKp.publicKey,
async onadd (auth) {
clearTimeout(timer)
try {
const dataBuf = auth && auth.data
if (!dataBuf || dataBuf.length === 0) {
reject(new Error('no device bundle received from source'))
return
}
const raw = JSON.parse(b4a.toString(dataBuf, 'utf-8'))
if (!raw || typeof raw !== 'object') {
reject(new Error('device link payload is not an object'))
return
}
// Normalize across the two wire formats produced by pgh's own
// device-linker (version:1 + -Hex suffixes) and pear-git-v3's
// device-linker (no version, -Key/-Pubkey names + deviceProof).
const normalized = {
version: 1,
label: raw.label || null,
rootPubkeyHex: raw.rootPubkeyHex || raw.rootPubkey || null,
devicePublicKeyHex: raw.devicePublicKeyHex || raw.devicePublicKey || null,
deviceSecretKeyHex: raw.deviceSecretKeyHex || raw.deviceSecretKey || null,
chainB64: raw.chainB64 || raw.deviceProof || null,
createdAt: raw.createdAt || Date.now(),
deploymentBundle: raw.deploymentBundle || null
}
if (!normalized.chainB64 || !normalized.deviceSecretKeyHex || !normalized.rootPubkeyHex) {
reject(new Error('device link payload missing required fields (chain / deviceSecretKey / rootPubkey)'))
return
}
resolve(normalized)
try { await bp.close() } catch {}
} catch (err) {
reject(err)
}
}
})
})
}