| … | … | @@ -0,0 +1,89 @@ |
| 1 | // pairing.js — hpp-initiated browser pairing over blind-pairing (the "hash URL" that opens a peer tunnel). |
| 2 | // |
| 3 | // Aligns with pgv3's DeviceLinker (blind-pairing). pgv3's browser auth is TOFU (the daemon IS the identity |
| 4 | // authority, so it self-registers a browser key). favela is KEYLESS — the authority is the remote hpp/DID — |
| 5 | // so a browser sub-key must be DELEGATED by the DID. The DID key-holder (hpp) mints a one-time invite; the |
| 6 | // candidate (favela-web, on the browser's behalf) joins → an E2E tunnel opens over the DHT → the browser's |
| 7 | // sub-key PUBKEY goes up → the hpp delegates it → the delegation comes back. Works REMOTELY (any hpp on the |
| 8 | // DHT) and is owner-gated by construction (only the key-holder can mint an invite; favela-web holds no key). |
| 9 | // |
| 10 | // Direction note: DeviceLinker's source PUSHES a bundle to the joiner; here the candidate must send its |
| 11 | // sub-key UP first, so the sub-key rides the candidate's blind-pairing `userData` (the member's hello) and |
| 12 | // the delegation comes back as the confirm's `additional.data`. |
| 13 | import Hyperswarm from 'hyperswarm' |
| 14 | import BlindPairing from 'blind-pairing' |
| 15 | import crypto from 'hypercore-crypto' |
| 16 | import b4a from 'b4a' |
| 17 | |
| 18 | const TIMEOUT_MS = 5 * 60 * 1000 |
| 19 | |
| 20 | // SOURCE (hpp): mint a one-time pairing invite. onSubkey(subKeyHex) => delegation object (device.delegate). |
| 21 | // Returns { inviteHex, waitForPeer, close }. Stays open for ONE pairing session (owner mints per browser). |
| 22 | export async function createPairInvite ({ onSubkey, swarm = null, timeout = TIMEOUT_MS } = {}) { |
| 23 | if (typeof onSubkey !== 'function') throw new Error('createPairInvite requires onSubkey(subKey) => delegation') |
| 24 | const pairingKey = crypto.keyPair() |
| 25 | const { invite, publicKey: invitePublicKey, discoveryKey, seed } = BlindPairing.createInvite(pairingKey.publicKey) |
| 26 | const inviteKeyPair = crypto.keyPair(seed) |
| 27 | const sw = swarm || new Hyperswarm() |
| 28 | const pairing = new BlindPairing(sw) |
| 29 | const done = new Promise((resolve, reject) => { |
| 30 | const timer = setTimeout(() => reject(new Error('createPairInvite: timeout waiting for the browser')), timeout) |
| 31 | pairing.addMember({ |
| 32 | discoveryKey, |
| 33 | onadd: async (request) => { |
| 34 | clearTimeout(timer) |
| 35 | try { |
| 36 | request.open(invitePublicKey) |
| 37 | const hello = JSON.parse(b4a.toString(request.userData)) // the candidate's sub-key |
| 38 | const delegation = await onSubkey(hello.subKey) |
| 39 | const data = b4a.from(JSON.stringify(delegation)) |
| 40 | request.confirm({ key: pairingKey.publicKey, encryptionKey: null, additional: { data, signature: crypto.sign(data, inviteKeyPair.secretKey) } }) |
| 41 | resolve({ subKey: hello.subKey, delegation }) |
| 42 | } catch (e) { reject(e) } |
| 43 | } |
| 44 | }) |
| 45 | }) |
| 46 | return { |
| 47 | inviteHex: b4a.toString(invite, 'hex'), |
| 48 | waitForPeer: () => done, |
| 49 | // DRAIN before teardown (mirrors pgv3 DeviceLinker.close). The confirm frame carrying the delegation |
| 50 | // is enqueued via protomux AFTER onadd returns, but waitForPeer resolves SYNCHRONOUSLY inside onadd — |
| 51 | // so the CLI's `await waitForPeer(); await close()` would destroy the swarm before the frame hits the |
| 52 | // wire. The candidate (favela-web) then never receives the delegation and hangs to timeout, while the |
| 53 | // source falsely reports success. Awaiting conn.flush() drains protomux→noise→UDX before destroy. |
| 54 | close: async () => { |
| 55 | try { |
| 56 | const flushes = [] |
| 57 | for (const conn of sw.connections) { if (typeof conn.flush === 'function') flushes.push(conn.flush()) } |
| 58 | await Promise.all(flushes).catch(() => {}) |
| 59 | } catch {} |
| 60 | try { await pairing.close() } catch {} |
| 61 | if (!swarm) { try { await sw.destroy() } catch {} } |
| 62 | } |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | // CANDIDATE (favela-web): join with the invite + the browser's sub-key pubkey → receive the delegation. |
| 67 | // swarm optional (an ephemeral one is created + destroyed). Throws on timeout. |
| 68 | export async function joinPairInvite (inviteHex, { subKey, swarm = null, timeout = TIMEOUT_MS } = {}) { |
| 69 | if (!subKey) throw new Error('joinPairInvite requires subKey') |
| 70 | const sw = swarm || new Hyperswarm() |
| 71 | const pairing = new BlindPairing(sw) |
| 72 | const userData = b4a.from(JSON.stringify({ subKey })) |
| 73 | try { |
| 74 | return await new Promise((resolve, reject) => { |
| 75 | const timer = setTimeout(() => reject(new Error('joinPairInvite: timeout')), timeout) |
| 76 | pairing.addCandidate({ |
| 77 | invite: b4a.from(inviteHex, 'hex'), |
| 78 | userData, |
| 79 | onadd: (result) => { |
| 80 | clearTimeout(timer) |
| 81 | try { resolve(JSON.parse(b4a.toString(result.data))) } catch (e) { reject(e) } |
| 82 | } |
| 83 | }) |
| 84 | }) |
| 85 | } finally { |
| 86 | try { await pairing.close() } catch {} |
| 87 | if (!swarm) { try { await sw.destroy() } catch {} } |
| 88 | } |
| 89 | } |