OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / client / pairing.js

704 lines · javascript ref:
/**
 * Pairing-over-swarm protocol for HiveRelay clients.
 *
 * Lets a user pair a second device by entering a short numeric code,
 * with no QR-code scanning or manual bundle copy required.
 *
 *   // Device A
 *   const { code } = await client.createPairingCode()
 *   // displays "123456" to the user
 *
 *   // Device B
 *   const result = await client.claimPairingCode('123456')
 *   // result.ok === true; client now uses the same identity as Device A
 *
 * Wire protocol — a Protomux channel `hiverelay-pair`. Every message in
 * the channel is a single length-prefixed UTF-8 JSON blob (Protomux
 * already gives us length framing per message; we just JSON-encode the
 * payload). The channel only opens when both ends have decided to
 * participate in pairing — non-pair connections (drives, relays, etc.)
 * silently ignore the protocol.
 *
 *   1. A → B   { type: 'challenge', nonce: <hex 32 bytes> }
 *   2. B → A   { type: 'challenge', nonce: <hex 32 bytes> }
 *   3. B → A   { type: 'proof', proof: HMAC-SHA256(code, A.nonce) }
 *   4. A → B   { type: 'proof', proof: HMAC-SHA256(code, B.nonce) }
 *   5. A → B   { type: 'identity', bundle: <exportIdentity()> }
 *   6. B → A   { type: 'ack' }
 *
 * The pair code itself is NEVER sent over the wire — only HMAC proofs.
 * The Hyperswarm Noise XK channel encrypts everything end-to-end, so
 * the relay (and any DHT peer that happens to learn the topic) cannot
 * read the bundle. The 32-byte topic is BLAKE2b(code) which still leaks
 * one bit of code-existence to anyone who guesses the topic — see
 * SECURITY NOTES at the bottom of this file.
 */

import b4a from 'b4a'
import sodium from 'sodium-universal'
// crypto only used for HMAC-SHA256 in proofFor(). Resolved via the
// package's `imports` map: Bare gets bare-crypto, Node gets node:crypto.
// All other random-byte / hash needs go through sodium-universal directly,
// which is Bare-native.
import crypto from 'crypto'
import Protomux from 'protomux'
import c from 'compact-encoding'

// sodium-based cryptographically secure random bytes. Replaces
// crypto.randomBytes() so the only Node-crypto dependency left in this
// file is HMAC-SHA256 (see proofFor below).
function randomBytes (length) {
  const buf = b4a.alloc(length)
  sodium.randombytes_buf(buf)
  return buf
}

export const PAIR_PROTOCOL = 'hiverelay-pair'
export const DEFAULT_TTL_MS = 5 * 60 * 1000 // 5 minutes
export const DEFAULT_CLAIM_TIMEOUT_MS = 30_000 // 30 seconds
export const CODE_DIGITS = 6
export const MAX_FRAME_BYTES = 64 * 1024 // bundle is small (~200 bytes); cap frames

/**
 * Hash a pair code to a 32-byte swarm topic.
 *
 * @param {string} code
 * @returns {Buffer}
 */
export function deriveTopic (code) {
  const out = b4a.alloc(32)
  sodium.crypto_generichash(out, b4a.from('hiverelay-pair:' + code, 'utf8'))
  return out
}

/**
 * HMAC-SHA256 of a nonce keyed by the pair code.
 */
export function proofFor (code, nonce) {
  const h = crypto.createHmac('sha256', b4a.from(code, 'utf8'))
  h.update(nonce)
  return h.digest()
}

/**
 * Cryptographically random zero-padded numeric code, uniform over
 * 0..10**digits-1.
 */
export function generateCode (digits = CODE_DIGITS) {
  const max = Math.pow(10, digits)
  // 4 bytes = 32-bit unsigned. Reject above largest multiple of `max` to
  // keep the distribution uniform.
  const limit = Math.floor(0xffffffff / max) * max
  let n
  do {
    const buf = randomBytes(4)
    n = buf.readUInt32BE(0)
  } while (n >= limit)
  return String(n % max).padStart(digits, '0')
}

function encodePairJson (msg) {
  const json = JSON.stringify(msg)
  if (b4a.byteLength(json) > MAX_FRAME_BYTES) throw new Error('frame too large')
  return json
}

// JSON-over-bytes Protomux message encoding. Protomux already length-prefixes
// each message, so we wrap a UTF-8 JSON blob in c.string. Decode peeks the
// declared compact string length before materializing it so oversized frames
// fail with a small protocol error instead of a large string allocation.
export const pairMessageEncoding = {
  preencode (state, msg) {
    c.string.preencode(state, encodePairJson(msg))
  },
  encode (state, msg) {
    c.string.encode(state, encodePairJson(msg))
  },
  decode (state) {
    const buffer = state && state.buffer
    if (!buffer || !Number.isSafeInteger(state.start) || !Number.isSafeInteger(state.end) || state.start < 0 || state.end < state.start || state.end > buffer.length) {
      if (state && Number.isSafeInteger(state.end)) state.start = Math.min(Math.max(state.end, 0), buffer ? buffer.length : state.end)
      return { type: -1, error: 'malformed JSON' }
    }

    let len
    const lenState = { buffer, start: state.start, end: state.end }
    try {
      len = c.uint.decode(lenState)
    } catch (_) {
      state.start = state.end
      return { type: -1, error: 'malformed JSON' }
    }

    if (!Number.isSafeInteger(len) || len < 0) {
      state.start = state.end
      return { type: -1, error: 'malformed JSON' }
    }

    if (len > MAX_FRAME_BYTES) {
      state.start = Math.min(state.end, lenState.start + len)
      return { type: -1, error: 'frame too large' }
    }

    const end = lenState.start + len
    if (end > state.end) {
      state.start = state.end
      return { type: -1, error: 'malformed JSON' }
    }

    const s = b4a.toString(buffer, 'utf8', lenState.start, end)
    state.start = end
    try {
      return JSON.parse(s)
    } catch (_) {
      return { type: -1, error: 'malformed JSON' }
    }
  }
}

/**
 * Build a pair-protocol Protomux channel on `conn` and return a
 * { send, recv, close } facade. recv() returns the next inbound
 * message or null on close. Returns null if a channel for this
 * protocol is already open on the conn.
 */
function openPairChannel (conn) {
  const mux = Protomux.from(conn)
  if (mux.opened({ protocol: PAIR_PROTOCOL })) return null

  const inbox = []
  let waiter = null
  let closed = false

  const deliver = (msg) => {
    if (waiter) {
      const w = waiter
      waiter = null
      w.resolve(msg)
    } else {
      inbox.push(msg)
    }
  }

  const failClose = () => {
    if (closed) return
    closed = true
    if (waiter) {
      const w = waiter
      waiter = null
      w.resolve(null)
    }
  }

  const channel = mux.createChannel({
    protocol: PAIR_PROTOCOL,
    id: null,
    onopen: () => {},
    onclose: () => failClose(),
    ondestroy: () => failClose()
  })
  if (!channel) return null

  const msg = channel.addMessage({
    encoding: pairMessageEncoding,
    onmessage: (m) => deliver(m)
  })

  channel.open()

  return {
    channel,
    send (m) {
      if (closed) return false
      try {
        msg.send(m)
        return true
      } catch (err) {
        failClose()
        return false
      }
    },
    async recv () {
      if (inbox.length) return inbox.shift()
      if (closed) return null
      return new Promise((resolve) => { waiter = { resolve } })
    },
    close () {
      try { channel.close() } catch (_) {}
      failClose()
    }
  }
}

/**
 * PairingManager — owns per-client pairing state.
 *
 * Attached to a HiveRelayClient via attachPairing(). Provides
 * createPairingCode() and claimPairingCode() methods.
 */
export class PairingManager {
  constructor (client, opts = {}) {
    this.client = client
    this.ttlMs = opts.ttlMs || DEFAULT_TTL_MS
    this.claimTimeoutMs = opts.claimTimeoutMs || DEFAULT_CLAIM_TIMEOUT_MS

    // code → { topic, expiresAt, ttlTimer, listener, claimed, complete }
    this._pending = new Map()

    // Per-peer rate limit on server-side pair attempts. A remote peer finding
    // our topic gets `maxAttemptsPerMinutePerPeer` chances per rolling minute
    // to open a pair channel. This caps online brute-force attempts against
    // the 6-digit code space (1M codes; with a 5-min default TTL and this
    // cap at 6/min/peer, a single peer gets ≤30 attempts per code window —
    // negligible success probability).
    this.maxAttemptsPerMinutePerPeer = opts.maxAttemptsPerMinutePerPeer ?? 6
    this.rateLimitWindowMs = opts.rateLimitWindowMs ?? 60_000
    // peerKey (hex) → { attempts, windowStart }
    this._peerAttempts = new Map()
  }

  /**
   * Token-bucket check for one pair-channel attempt from a given peer.
   * Returns true if allowed (and counts the attempt), false if blocked.
   */
  _checkPeerRateLimit (peerKey) {
    if (!peerKey || this.maxAttemptsPerMinutePerPeer <= 0) return true
    const now = Date.now()
    let bucket = this._peerAttempts.get(peerKey)
    if (!bucket || (now - bucket.windowStart) >= this.rateLimitWindowMs) {
      bucket = { attempts: 1, windowStart: now }
      this._peerAttempts.set(peerKey, bucket)
      return true
    }
    if (bucket.attempts >= this.maxAttemptsPerMinutePerPeer) return false
    bucket.attempts++
    return true
  }

  /**
   * Create a pair code. Spins up a swarm join on the derived topic.
   * Returns { code, expiresAt, topic }.
   */
  async createPairingCode (opts = {}) {
    const client = this.client
    if (!client._started) throw new Error('Client not started — call await app.start() first')
    if (!client.swarm) throw new Error('No swarm available')
    if (!client.keyPair || !client.keyPair.secretKey) throw new Error('No identity to share')

    const ttlMs = opts.ttlMs || this.ttlMs
    const code = opts.code || generateCode()
    if (!/^[0-9]{6}$/.test(code)) throw new Error('code must be 6 digits')

    const topic = deriveTopic(code)
    const expiresAt = Date.now() + ttlMs

    const state = {
      code,
      topic,
      expiresAt,
      claimed: false,
      complete: false,
      ttlTimer: null,
      listener: null,
      activeChannel: null
    }

    // Server-side: react when the REMOTE opens a pair channel. We
    // register a Protomux pair handler on every existing & future conn.
    // If we tried to open the channel proactively, an unrelated peer
    // would lock the per-conn channel slot and break later attempts.
    const muxsTouched = new WeakSet()

    const armConn = (conn) => {
      if (state.complete || state._cleaned) return
      if (muxsTouched.has(conn)) return
      muxsTouched.add(conn)
      const mux = Protomux.from(conn)
      mux.pair({ protocol: PAIR_PROTOCOL }, () => {
        if (state.complete || state._cleaned) return

        // Rate-limit on the remote swarm pubkey. Hyperswarm identity is
        // stabler than IP over a P2P net, and identifies a peer across
        // reconnects. Bucket is counted *before* we open the channel so a
        // rejected attempt never touches the handshake.
        const peerKey = conn.remotePublicKey ? b4a.toString(conn.remotePublicKey, 'hex') : null
        if (!this._checkPeerRateLimit(peerKey)) {
          client.emit('pairing-rate-limited', { role: 'server', code, peerKey })
          return
        }

        const ch = openPairChannel(conn)
        if (!ch) return
        this._runServer(state, ch, conn).catch((err) => {
          client.emit('pairing-failed', { role: 'server', code, reason: err.message })
        })
      })
    }

    const onConnection = (conn, _peerInfo) => armConn(conn)
    state.listener = onConnection
    client.swarm.on('connection', onConnection)

    // TTL — if no successful claim happens, clean up.
    state.ttlTimer = setTimeout(() => {
      if (state.complete) return
      this._cleanup(state, 'expired').catch(() => {})
    }, ttlMs)
    if (state.ttlTimer.unref) state.ttlTimer.unref()

    // Join the swarm as server. We don't connect outward.
    client.swarm.join(topic, { server: true, client: false })
    client.swarm.flush().catch(() => {})

    // Arm any existing connections too — on a long-lived swarm the peer
    // for this topic may already be connected via another topic (e.g.
    // the relay-discovery topic). Hyperswarm doesn't fire a new
    // 'connection' event when an existing peer adds a topic.
    if (client.swarm.connections) {
      for (const conn of client.swarm.connections) armConn(conn)
    }

    state._armed = muxsTouched
    this._pending.set(code, state)

    return { code, expiresAt, topic }
  }

  /**
   * Claim a pair code (Device B side). Joins swarm as client, runs the
   * client side of the handshake, imports the identity if successful.
   *
   * Returns { ok: true, identity: { publicKey } } on success or
   * { ok: false, reason: '...' } on failure.
   */
  async claimPairingCode (code, opts = {}) {
    const client = this.client
    if (!client._started) throw new Error('Client not started — call await app.start() first')
    if (!client.swarm) throw new Error('No swarm available')

    if (typeof code !== 'string' || !/^[0-9]{6}$/.test(code)) {
      return { ok: false, reason: 'invalid-code-format' }
    }

    const timeoutMs = opts.timeoutMs || this.claimTimeoutMs
    const topic = deriveTopic(code)

    let resolveResult
    let settled = false
    const result = new Promise((resolve) => { resolveResult = resolve })
    const settle = (value) => {
      if (settled) return
      settled = true
      resolveResult(value)
    }

    const tried = new WeakSet()
    const tryConn = (conn) => {
      if (settled) return
      if (tried.has(conn)) return
      tried.add(conn)
      const ch = openPairChannel(conn)
      if (!ch) return
      this._runClient(code, ch).then((res) => {
        if (res && res.ok) settle(res)
        else if (!settled) {
          // keep listening for other peers in case this conn was a stray;
          // but most likely there is only one.
          try { ch.close() } catch (_) {}
        }
      }).catch(() => {
        try { ch.close() } catch (_) {}
      })
    }

    // Open a pair channel on every existing AND future connection.
    // The server side uses Protomux `mux.pair` (reactive), so opening
    // proactively here is what kicks the handshake off. Conns that
    // belong to non-pairing peers will simply have an idle channel that
    // times out after the per-message recv timeout.
    const onConnection = (conn, _peerInfo) => tryConn(conn)
    client.swarm.on('connection', onConnection)

    const timer = setTimeout(() => {
      settle({ ok: false, reason: 'timeout' })
    }, timeoutMs)
    if (timer.unref) timer.unref()

    try {
      client.swarm.join(topic, { server: false, client: true })
      client.swarm.flush().catch(() => {})
    } catch (err) {
      clearTimeout(timer)
      client.swarm.removeListener('connection', onConnection)
      return { ok: false, reason: err.message }
    }

    // Open against existing connections — the responder may already be
    // connected via another topic so no fresh 'connection' will fire.
    if (client.swarm.connections) {
      for (const conn of client.swarm.connections) tryConn(conn)
    }

    let final
    try {
      final = await result
    } finally {
      clearTimeout(timer)
      client.swarm.removeListener('connection', onConnection)
      try { await client.swarm.leave(topic) } catch (_) {}
    }

    if (final.ok) {
      client.emit('pairing-completed', { role: 'client', publicKey: final.identity.publicKey })
    } else {
      client.emit('pairing-failed', { role: 'client', reason: final.reason })
    }
    return final
  }

  /**
   * Server-side handshake on one pair channel.
   */
  async _runServer (state, ch, conn) {
    const client = this.client
    state.activeChannel = ch

    try {
      // 1) Send our challenge.
      const myNonce = randomBytes(32)
      ch.send({ type: 'challenge', nonce: myNonce.toString('hex') })

      // 2) Receive their challenge.
      const theirChallenge = await this._recvWithTimeout(ch, 10_000)
      if (!theirChallenge || theirChallenge.type !== 'challenge' || typeof theirChallenge.nonce !== 'string') {
        throw new Error('bad-challenge')
      }
      const theirNonce = b4a.from(theirChallenge.nonce, 'hex')
      if (theirNonce.length !== 32) throw new Error('bad-challenge')

      // 3) Receive their proof.
      const proofMsg = await this._recvWithTimeout(ch, 10_000)
      if (!proofMsg || proofMsg.type !== 'proof' || typeof proofMsg.proof !== 'string') {
        throw new Error('bad-proof')
      }
      const expected = proofFor(state.code, myNonce)
      const got = b4a.from(proofMsg.proof, 'hex')
      if (got.length !== expected.length || !sodium.sodium_memcmp(got, expected)) {
        // Wrong code on the other side. Tear down this channel but keep
        // the pairing alive so the user can retry a typo.
        throw new Error('bad-proof')
      }

      // From this point on we trust the peer.
      state.claimed = true

      // 4) Send our proof.
      ch.send({ type: 'proof', proof: proofFor(state.code, theirNonce).toString('hex') })

      // 5) Send the identity bundle.
      const bundle = client.exportIdentity()
      ch.send({ type: 'identity', bundle })

      // 6) Wait for ack.
      const ack = await this._recvWithTimeout(ch, 10_000)
      if (!ack || ack.type !== 'ack') throw new Error('no-ack')

      state.complete = true
      client.emit('pairing-completed', { role: 'server', code: state.code, publicKey: bundle.publicKey })
      await this._cleanup(state, 'completed')
    } catch (err) {
      try { ch.close() } catch (_) {}
      if (state.claimed) {
        // Past point of no return — clean up.
        state.complete = true
        await this._cleanup(state, 'failed-after-claim')
      }
      // Don't tear down conn itself — other protocols (replication) may use it.
      // (Intentionally leaving `conn` alive; ignore the eslint unused-var lint.)
      // Only emit on bad-proof / no-ack to avoid noise from non-pair peers
      // that never even opened the channel.
      if (err.message !== 'recv-timeout') {
        client.emit('pairing-failed', { role: 'server', code: state.code, reason: err.message })
      }
    }
  }

  /**
   * Client-side handshake on one pair channel.
   * Returns { ok, identity?, reason? }.
   */
  async _runClient (code, ch) {
    const client = this.client

    try {
      // 1) Receive their challenge.
      const theirChallenge = await this._recvWithTimeout(ch, 10_000)
      if (!theirChallenge || theirChallenge.type !== 'challenge' || typeof theirChallenge.nonce !== 'string') {
        return { ok: false, reason: 'bad-challenge' }
      }
      const theirNonce = b4a.from(theirChallenge.nonce, 'hex')
      if (theirNonce.length !== 32) return { ok: false, reason: 'bad-challenge' }

      // 2) Send our challenge.
      const myNonce = randomBytes(32)
      ch.send({ type: 'challenge', nonce: myNonce.toString('hex') })

      // 3) Send our proof of `code`.
      ch.send({ type: 'proof', proof: proofFor(code, theirNonce).toString('hex') })

      // 4) Receive their proof.
      const proofMsg = await this._recvWithTimeout(ch, 10_000)
      if (!proofMsg || proofMsg.type !== 'proof' || typeof proofMsg.proof !== 'string') {
        return { ok: false, reason: 'bad-proof' }
      }
      const expected = proofFor(code, myNonce)
      const got = b4a.from(proofMsg.proof, 'hex')
      if (got.length !== expected.length || !sodium.sodium_memcmp(got, expected)) {
        return { ok: false, reason: 'bad-proof' }
      }

      // 5) Receive identity bundle.
      const idMsg = await this._recvWithTimeout(ch, 15_000)
      if (!idMsg || idMsg.type !== 'identity' || !idMsg.bundle) {
        return { ok: false, reason: 'no-identity' }
      }

      // 6) Import & ack.
      try {
        client.importIdentity(idMsg.bundle)
      } catch (err) {
        ch.send({ type: 'nack', reason: err.message })
        return { ok: false, reason: 'invalid-identity' }
      }
      ch.send({ type: 'ack' })

      return { ok: true, identity: { publicKey: idMsg.bundle.publicKey } }
    } catch (err) {
      return { ok: false, reason: err.message || 'client-error' }
    }
  }

  async _recvWithTimeout (ch, ms) {
    let timer
    const timeout = new Promise((resolve) => {
      timer = setTimeout(() => resolve('__timeout__'), ms)
      if (timer.unref) timer.unref()
    })
    try {
      const r = await Promise.race([ch.recv(), timeout])
      if (r === '__timeout__') {
        const err = new Error('recv-timeout')
        throw err
      }
      return r
    } finally {
      clearTimeout(timer)
    }
  }

  async _cleanup (state, reason) {
    const client = this.client
    if (state._cleaned) return
    state._cleaned = true
    if (state.ttlTimer) {
      clearTimeout(state.ttlTimer)
      state.ttlTimer = null
    }
    if (state.listener) {
      try { client.swarm.removeListener('connection', state.listener) } catch (_) {}
      state.listener = null
    }
    // Best-effort: unpair from every conn we armed. We can't iterate
    // a WeakSet, so we walk the swarm's current connections.
    if (client.swarm && client.swarm.connections) {
      for (const conn of client.swarm.connections) {
        try {
          const mux = Protomux.from(conn)
          mux.unpair({ protocol: PAIR_PROTOCOL })
        } catch (_) {}
      }
    }
    try { if (state.activeChannel) state.activeChannel.close() } catch (_) {}
    try { await client.swarm.leave(state.topic) } catch (_) {}
    this._pending.delete(state.code)
    if (reason === 'expired') {
      client.emit('pairing-failed', { role: 'server', code: state.code, reason: 'expired' })
    }
  }

  /**
   * Drop all pending codes. Called on client.destroy().
   */
  async destroy () {
    const states = Array.from(this._pending.values())
    this._pending.clear()
    for (const state of states) {
      try { await this._cleanup(state, 'shutdown') } catch (_) {}
    }
    this._peerAttempts.clear()
  }

  /**
   * Evict stale rate-limit buckets whose windows have already elapsed.
   * Safe to call at any time. Intentionally not scheduled on a timer —
   * the bucket check does its own window-expiry check on access, so stale
   * entries don't affect correctness; this method only reclaims memory.
   */
  _prunePeerAttempts () {
    const now = Date.now()
    const staleThreshold = this.rateLimitWindowMs * 2
    for (const [key, bucket] of this._peerAttempts) {
      if ((now - bucket.windowStart) > staleThreshold) this._peerAttempts.delete(key)
    }
  }

  /** Test-only: snapshot of currently-pending codes. */
  _pendingCodes () {
    return Array.from(this._pending.keys())
  }
}

/**
 * Attach pairing methods to a HiveRelayClient instance. Idempotent.
 */
export function attachPairing (client, opts = {}) {
  if (client._pairing) return client._pairing
  const mgr = new PairingManager(client, opts)
  client._pairing = mgr
  return mgr
}

/* ─── SECURITY NOTES ────────────────────────────────────────────────
 *
 * 1. Topic leakage. The 32-byte swarm topic is BLAKE2b of the code.
 *    Anyone watching the DHT can guess 6-digit codes (a million
 *    possibilities) and check whether each topic has announcers. Today
 *    that just gives existence-of-active-pairing. They still cannot
 *    complete the handshake without the code (HMAC proof gates the
 *    bundle transfer). To raise this bar further, we could rate-limit
 *    announce churn or salt the topic with a fixed application string
 *    per deployment.
 *
 * 2. Online brute force. An attacker who guesses the topic AND has
 *    network capacity could try 1,000,000 candidate codes against an
 *    active pairing. Because the proof requires the responder's nonce
 *    (round trip), it is online and rate-limited by handshake setup
 *    (Hyperswarm Noise XK ~50ms minimum). 5 minutes / 50ms = 6000
 *    attempts maximum. Single-claim semantics close the window after
 *    one peer succeeds. For higher security, use a longer code or add
 *    server-side claim-attempt rate limiting.
 *
 * 3. HMAC alone vs PAKE. A proper Password-Authenticated Key Exchange
 *    (SPAKE2 / OPAQUE) would derive a session key from the code AND
 *    bind it to the channel, preventing offline-brute-force even if
 *    the transcript leaks. We get away with simple HMAC because the
 *    Hyperswarm channel is already Noise XK encrypted — an off-path
 *    attacker cannot see the proofs at all. An on-path active attacker
 *    impersonating both ends would have to know the code to forge
 *    proofs, so MITM is not viable without it.
 *
 * 4. Identity bundle exposure. The bundle is the raw private key. It
 *    is sent over the Noise XK channel which is forward-secret to
 *    third parties. Both endpoints obviously have the key after pairing.
 */