| … | … | @@ -64,31 +64,17 @@ export function decodeRoomPairingInvite (str) { |
| 64 | 64 | return { bootstrapKey: o.bootstrapKey, role: o.role || 'reader', meta: o.meta || null } |
| 65 | 65 | } |
| 66 | 66 | |
| 67 | | /** |
| 68 | | * Host side. `room` must be ready and writable (the owner). Returns a |
| 69 | | * shareable `invite` string + a `close()` to tear down the pairing member. |
| 70 | | * @param {Room} room |
| 71 | | * @param {{ role?: string, swarm: object }} opts |
| 72 | | * @returns {{ invite: string, discoveryKey: Buffer, close: () => Promise<void> }} |
| 73 | | */ |
| 74 | | export function createRoomPairing (room, opts = {}) { |
| 75 | | const role = opts.role || 'reader' |
| 76 | | const swarm = opts.swarm |
| 77 | | if (!swarm) throw new Error('createRoomPairing requires { swarm }') |
| 78 | | if (!ROLES.includes(role)) throw new Error('unknown role: ' + role) |
| 79 | | if (!room || !room.keyHex) throw new Error('createRoomPairing requires a ready room') |
| 80 | | |
| 81 | | const pairingKey = crypto.keyPair() |
| 82 | | const { invite, publicKey, discoveryKey, seed } = BlindPairing.createInvite(pairingKey.publicKey) |
| 83 | | const inviteKp = crypto.keyPair(seed) |
| 84 | | |
| 67 | // Register a blind-pairing member that, on candidate pair, addWriters the |
| 68 | // candidate (contributor+) and confirms. Shared by createRoomPairing (fresh |
| 69 | // invite) and resumeRoomPairing (re-register a persisted invite on boot). |
| 70 | function registerPairingMember ({ room, role, swarm, pairingPublicKey, invitePublicKey, inviteKp, discoveryKey }) { |
| 85 | 71 | const bp = new BlindPairing(swarm) |
| 86 | 72 | const member = bp.addMember({ |
| 87 | 73 | discoveryKey, |
| 88 | 74 | onadd: async (request) => { |
| 89 | 75 | // The candidate's userData is its Room localKeyHex (the writer key it |
| 90 | 76 | // appends from). open() returns it after invite verification. |
| 91 | | const userData = request.open(publicKey) |
| 77 | const userData = request.open(invitePublicKey) |
| 92 | 78 | const writerKeyHex = userData ? b4a.toString(userData, 'utf-8') : null |
| 93 | 79 | try { |
| 94 | 80 | if (role !== 'reader' && writerKeyHex && writerKeyHex.length === 64) { |
| … | … | @@ -103,16 +89,13 @@ export function createRoomPairing (room, opts = {}) { |
| 103 | 89 | } |
| 104 | 90 | const ack = b4a.from(JSON.stringify({ ok: true, role }), 'utf-8') |
| 105 | 91 | request.confirm({ |
| 106 | | key: pairingKey.publicKey, |
| 92 | key: pairingPublicKey, |
| 107 | 93 | encryptionKey: null, |
| 108 | 94 | additional: { data: ack, signature: crypto.sign(ack, inviteKp.secretKey) } |
| 109 | 95 | }) |
| 110 | 96 | } |
| 111 | 97 | }) |
| 112 | | |
| 113 | 98 | return { |
| 114 | | invite: encodeInvite({ v: 1, invite: b4a.toString(invite, 'hex'), bootstrapKey: room.keyHex, role, meta: opts.meta || null }), |
| 115 | | discoveryKey, |
| 116 | 99 | async close () { |
| 117 | 100 | // Drain so the confirm ack reaches the candidate before teardown |
| 118 | 101 | // (same flush-before-close race device-linker.js documents). |
| … | … | @@ -126,6 +109,73 @@ export function createRoomPairing (room, opts = {}) { |
| 126 | 109 | } |
| 127 | 110 | } |
| 128 | 111 | |
| 112 | /** |
| 113 | * Host side. `room` must be ready and writable (the owner). Returns the |
| 114 | * shareable `invite`, a persistable `descriptor` (to re-register after a |
| 115 | * restart), and a `close()`. |
| 116 | * @param {Room} room |
| 117 | * @param {{ role?: string, swarm: object, meta?: object }} opts |
| 118 | * @returns {{ invite: string, descriptor: object, discoveryKey: Buffer, close: () => Promise<void> }} |
| 119 | */ |
| 120 | export function createRoomPairing (room, opts = {}) { |
| 121 | const role = opts.role || 'reader' |
| 122 | const swarm = opts.swarm |
| 123 | if (!swarm) throw new Error('createRoomPairing requires { swarm }') |
| 124 | if (!ROLES.includes(role)) throw new Error('unknown role: ' + role) |
| 125 | if (!room || !room.keyHex) throw new Error('createRoomPairing requires a ready room') |
| 126 | |
| 127 | const pairingKey = crypto.keyPair() |
| 128 | const { invite, publicKey, discoveryKey, seed } = BlindPairing.createInvite(pairingKey.publicKey) |
| 129 | const inviteKp = crypto.keyPair(seed) |
| 130 | |
| 131 | const { close } = registerPairingMember({ |
| 132 | room, role, swarm, pairingPublicKey: pairingKey.publicKey, invitePublicKey: publicKey, inviteKp, discoveryKey |
| 133 | }) |
| 134 | |
| 135 | const invitation = encodeInvite({ v: 1, invite: b4a.toString(invite, 'hex'), bootstrapKey: room.keyHex, role, meta: opts.meta || null }) |
| 136 | // Persistable descriptor — re-register this exact pairing member after a |
| 137 | // restart (via resumeRoomPairing) so an already-shared invite keeps working. |
| 138 | // Only `seed` is sensitive (the invite secret, owner-held); no writer or |
| 139 | // identity secret is stored. |
| 140 | const descriptor = { |
| 141 | role, |
| 142 | meta: opts.meta || null, |
| 143 | bootstrapKey: room.keyHex, |
| 144 | invitation, |
| 145 | discoveryKey: b4a.toString(discoveryKey, 'hex'), |
| 146 | invitePublicKey: b4a.toString(publicKey, 'hex'), |
| 147 | pairingPublicKey: b4a.toString(pairingKey.publicKey, 'hex'), |
| 148 | seed: b4a.toString(seed, 'hex') |
| 149 | } |
| 150 | return { invite: invitation, descriptor, discoveryKey, close } |
| 151 | } |
| 152 | |
| 153 | /** |
| 154 | * Re-register a pairing member from a persisted descriptor (after a daemon |
| 155 | * restart) so an already-shared invite keeps pairing. `room` must be the same |
| 156 | * room the descriptor was minted for (matching bootstrapKey). |
| 157 | * @returns {{ close: () => Promise<void> }} |
| 158 | */ |
| 159 | export function resumeRoomPairing (room, descriptor, swarm) { |
| 160 | if (!swarm) throw new Error('resumeRoomPairing requires a swarm') |
| 161 | if (!descriptor || !descriptor.discoveryKey || !descriptor.seed) { |
| 162 | throw new Error('resumeRoomPairing requires a descriptor with { discoveryKey, seed, ... }') |
| 163 | } |
| 164 | if (!room || !room.keyHex) throw new Error('resumeRoomPairing requires a ready room') |
| 165 | if (descriptor.bootstrapKey && room.keyHex !== descriptor.bootstrapKey) { |
| 166 | throw new Error('resumeRoomPairing: room does not match descriptor bootstrapKey') |
| 167 | } |
| 168 | return registerPairingMember({ |
| 169 | room, |
| 170 | role: descriptor.role || 'reader', |
| 171 | swarm, |
| 172 | pairingPublicKey: b4a.from(descriptor.pairingPublicKey, 'hex'), |
| 173 | invitePublicKey: b4a.from(descriptor.invitePublicKey, 'hex'), |
| 174 | inviteKp: crypto.keyPair(b4a.from(descriptor.seed, 'hex')), |
| 175 | discoveryKey: b4a.from(descriptor.discoveryKey, 'hex') |
| 176 | }) |
| 177 | } |
| 178 | |
| 129 | 179 | /** |
| 130 | 180 | * Candidate side. Mounts the room from the invite's bootstrapKey, presents |
| 131 | 181 | * its localKeyHex over pairing, returns the mounted Room. For contributor+ |