OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / device-linker.js

194 lines · javascript ref:
/**
 * 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)
        }
      }
    })
  })
}