// pairing.js — hpp-initiated browser pairing over blind-pairing (the "hash URL" that opens a peer tunnel).
//
// Aligns with pgv3's DeviceLinker (blind-pairing). pgv3's browser auth is TOFU (the daemon IS the identity
// authority, so it self-registers a browser key). favela is KEYLESS — the authority is the remote hpp/DID —
// so a browser sub-key must be DELEGATED by the DID. The DID key-holder (hpp) mints a one-time invite; the
// candidate (favela-web, on the browser's behalf) joins → an E2E tunnel opens over the DHT → the browser's
// sub-key PUBKEY goes up → the hpp delegates it → the delegation comes back. Works REMOTELY (any hpp on the
// DHT) and is owner-gated by construction (only the key-holder can mint an invite; favela-web holds no key).
//
// Direction note: DeviceLinker's source PUSHES a bundle to the joiner; here the candidate must send its
// sub-key UP first, so the sub-key rides the candidate's blind-pairing `userData` (the member's hello) and
// the delegation comes back as the confirm's `additional.data`.
import Hyperswarm from 'hyperswarm'
import BlindPairing from 'blind-pairing'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
const TIMEOUT_MS = 5 * 60 * 1000
// SOURCE (hpp): mint a one-time pairing invite. onSubkey(subKeyHex) => delegation object (device.delegate).
// Returns { inviteHex, waitForPeer, close }. Stays open for ONE pairing session (owner mints per browser).
export async function createPairInvite ({ onSubkey, swarm = null, timeout = TIMEOUT_MS } = {}) {
if (typeof onSubkey !== 'function') throw new Error('createPairInvite requires onSubkey(subKey) => delegation')
const pairingKey = crypto.keyPair()
const { invite, publicKey: invitePublicKey, discoveryKey, seed } = BlindPairing.createInvite(pairingKey.publicKey)
const inviteKeyPair = crypto.keyPair(seed)
const sw = swarm || new Hyperswarm()
const pairing = new BlindPairing(sw)
const done = new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('createPairInvite: timeout waiting for the browser')), timeout)
pairing.addMember({
discoveryKey,
onadd: async (request) => {
clearTimeout(timer)
try {
request.open(invitePublicKey)
const hello = JSON.parse(b4a.toString(request.userData)) // the candidate's sub-key
const delegation = await onSubkey(hello.subKey)
const data = b4a.from(JSON.stringify(delegation))
request.confirm({ key: pairingKey.publicKey, encryptionKey: null, additional: { data, signature: crypto.sign(data, inviteKeyPair.secretKey) } })
resolve({ subKey: hello.subKey, delegation })
} catch (e) {
// ⛔ TELL THE CANDIDATE. Rejecting only settles OUR promise; the joiner is left holding an open
// tunnel with nothing coming back, so it waits out its full timeout (5 min) and reports
// nothing about why. That is exactly how a BROWSER invite pasted into a NODE presents: the
// node sends a 64-hex keet device key, delegate() demands 66-hex secp256k1 and throws here,
// and the node just… hangs. A refusal is information the other side needs.
//
// Confirm with an { error } payload — the same shape mintAdoptInvite already returns when a
// human declines — then reject locally. Best-effort: if the channel is already gone there is
// nothing to say and the timeout remains the backstop.
try {
const data = b4a.from(JSON.stringify({ error: (e && e.message) || String(e) }))
request.confirm({ key: pairingKey.publicKey, encryptionKey: null, additional: { data, signature: crypto.sign(data, inviteKeyPair.secretKey) } })
} catch { /* channel gone — the candidate falls back to its timeout */ }
reject(e)
}
}
})
})
return {
inviteHex: b4a.toString(invite, 'hex'),
waitForPeer: () => done,
// DRAIN before teardown (mirrors pgv3 DeviceLinker.close). The confirm frame carrying the delegation
// is enqueued via protomux AFTER onadd returns, but waitForPeer resolves SYNCHRONOUSLY inside onadd —
// so the CLI's `await waitForPeer(); await close()` would destroy the swarm before the frame hits the
// wire. The candidate (favela-web) then never receives the delegation and hangs to timeout, while the
// source falsely reports success. Awaiting conn.flush() drains protomux→noise→UDX before destroy.
close: async () => {
try {
const flushes = []
for (const conn of sw.connections) { if (typeof conn.flush === 'function') flushes.push(conn.flush()) }
await Promise.all(flushes).catch(() => {})
} catch {}
try { await pairing.close() } catch {}
if (!swarm) { try { await sw.destroy() } catch {} }
}
}
}
// CANDIDATE (favela-web): join with the invite + the browser's sub-key pubkey → receive the delegation.
// swarm optional (an ephemeral one is created + destroyed). Throws on timeout.
export async function joinPairInvite (inviteHex, { subKey, swarm = null, timeout = TIMEOUT_MS } = {}) {
if (!subKey) throw new Error('joinPairInvite requires subKey')
const sw = swarm || new Hyperswarm()
const pairing = new BlindPairing(sw)
const userData = b4a.from(JSON.stringify({ subKey }))
try {
return await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('joinPairInvite: timeout')), timeout)
pairing.addCandidate({
invite: b4a.from(inviteHex, 'hex'),
userData,
onadd: (result) => {
clearTimeout(timer)
try { resolve(JSON.parse(b4a.toString(result.data))) } catch (e) { reject(e) }
}
})
})
} finally {
try { await pairing.close() } catch {}
if (!swarm) { try { await sw.destroy() } catch {} }
}
}