OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-room commits / 26f21f0

pairing: move blind-pairing here so any node can mint an invite
The module lived in favela-web, so only trees depending on the WEB app could mint one — a phone could join and never invite, and hyper-proto-peer (which owns the ceremony the tunnel carries) could not reach it at all. It was always generic: hyperswarm, blind-pairing, hypercore-crypto, b4a. Nothing favela-shaped crossed it. favela-web re-exports from here, so its importers are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
26f21f05981e50f5731de15c5c5267ad2843da14
by plankatron <fleeky@gmail.com> · 2026-08-09T22:59:17.000Z

Changed files (3) +94 -4

lib/pairing.js +89 -0
……@@ -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`.
13import Hyperswarm from 'hyperswarm'
14import BlindPairing from 'blind-pairing'
15import crypto from 'hypercore-crypto'
16import b4a from 'b4a'
17
18const 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).
22export 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.
68export 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}
package-lock.json +2 -2
……@@ -1,12 +1,12 @@
11{
22 "name": "atproto-room",
3 "version": "0.2.0",
3 "version": "0.2.18",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "atproto-room",
9 "version": "0.2.0",
9 "version": "0.2.18",
1010 "hasInstallScript": true,
1111 "dependencies": {
1212 "@ipld/dag-cbor": "^9.2.1",
package.json +3 -2
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-room",
3 "version": "0.2.17",
3 "version": "0.2.18",
44 "description": "Attestation-gated, encrypted multi-writer room engine — StateBase (keet-model admission) + Lease + signer + RepoBase + LabelBase/LabelRoom + serve-membrane. Extracted from hyper-proto-peer; consumed by the atproto peer (repo devices) and the co-authored labeler.",
55 "type": "module",
66 "main": "index.js",
……@@ -10,15 +10,16 @@
1010 "postinstall": "node scripts/bare-deps.mjs"
1111 },
1212 "dependencies": {
13 "@ipld/dag-cbor": "^9.2.1",
1314 "atproto-identity": "file:../atproto-identity",
1415 "atproto-repo": "file:../atproto-repo",
1516 "atproto-store": "file:../atproto-store",
16 "@ipld/dag-cbor": "^9.2.1",
1717 "autobase": "7.28.1",
1818 "b4a": "^1.6.0",
1919 "bare-fs": "^4.7.1",
2020 "bare-path": "^3.0.1",
2121 "bare-process": "^4.2.1",
22 "blind-pairing": "^2.3.1",
2223 "compact-encoding": "^2.16.0",
2324 "corestore": "^7.10.0",
2425 "hypercore": "^11.33.1",