| … | … | @@ -106,8 +106,14 @@ export class InstanceLinker { |
| 106 | 106 | } |
| 107 | 107 | }) |
| 108 | 108 | |
| 109 | // Don't add writer yet — joiner's writerKey changes after re-init | |
| 110 | // Joiner will call back via HTTP with correct writer key | |
| 109 | // Admit the joiner's writer key NOW, bound to this authenticated | |
| 110 | // pairing handshake (their key is stable/bootstrap-independent and | |
| 111 | // arrived as signed userData). Replaces the HTTP callback token. | |
| 112 | if (joinerInfo && joinerInfo.writerKey) { | |
| 113 | this._registry.addWriter(joinerInfo.writerKey).catch((err) => { | |
| 114 | console.error('[InstanceLinker] addWriter during pairing failed:', err.message) | |
| 115 | }) | |
| 116 | } | |
| 111 | 117 | resolve(joinerInfo) |
| 112 | 118 | } catch (err) { |
| 113 | 119 | reject(err) |
| … | … | @@ -134,9 +140,13 @@ export class InstanceLinker { |
| 134 | 140 | async joinWithInvite (inviteHex) { |
| 135 | 141 | const inviteBuf = Buffer.from(inviteHex, 'hex') |
| 136 | 142 | |
| 137 | // Send our instance key as userData (NOT writerKey — it changes after re-init) | |
| 143 | // Send instanceKey + our STABLE writer key as userData. The writer key is | |
| 144 | // bootstrap-independent (deterministic keypair), so it is the same before | |
| 145 | // and after re-init — the source can admit it over THIS authenticated | |
| 146 | // handshake instead of an unauthenticated HTTP callback. | |
| 138 | 147 | const joinerInfo = { |
| 139 | instanceKey: this._instanceKey | |
| 148 | instanceKey: this._instanceKey, | |
| 149 | writerKey: this._registry.localWriterKey.toString('hex') | |
| 140 | 150 | } |
| 141 | 151 | const joinerDataBuf = Buffer.from(JSON.stringify(joinerInfo), 'utf-8') |
| 142 | 152 |
| … | … | @@ -53,8 +53,13 @@ export class MultiwriterManager { |
| 53 | 53 | async ready () { |
| 54 | 54 | if (this._ready) return |
| 55 | 55 | |
| 56 | // Stable per-instance writer keypair (deterministic from the corestore | |
| 57 | // primary key, bootstrap-independent) so our writer key is known BEFORE | |
| 58 | // pairing and admission can ride the authenticated handshake. | |
| 59 | this._writerKeyPair = await this._store.createKeyPair('multiwriter-writer') | |
| 60 | ||
| 56 | 61 | // Initialize shared registry |
| 57 | const regOpts = {} | |
| 62 | const regOpts = { keyPair: this._writerKeyPair } | |
| 58 | 63 | if (this._registryBootstrapKey) { |
| 59 | 64 | regOpts.bootstrapKey = this._registryBootstrapKey |
| 60 | 65 | } |
| … | … | @@ -151,7 +156,7 @@ export class MultiwriterManager { |
| 151 | 156 | // 2. Create NEW SharedRegistry with source's bootstrapKey |
| 152 | 157 | const bootstrapBuf = Buffer.isBuffer(bootstrapKey) |
| 153 | 158 | ? bootstrapKey : Buffer.from(bootstrapKey, 'hex') |
| 154 | this._registry = new SharedRegistry(this._store, { bootstrapKey: bootstrapBuf }) | |
| 159 | this._registry = new SharedRegistry(this._store, { bootstrapKey: bootstrapBuf, keyPair: this._writerKeyPair }) | |
| 155 | 160 | await this._registry.ready() |
| 156 | 161 | |
| 157 | 162 | // 3. Get our new localWriterKey from the SHARED registry |
| … | … | @@ -186,25 +191,12 @@ export class MultiwriterManager { |
| 186 | 191 | } |
| 187 | 192 | } |
| 188 | 193 | |
| 189 | // 8. HTTP callback — tell source our new writer key | |
| 190 | if (instanceUrl) { | |
| 191 | try { | |
| 192 | console.log('[Multiwriter] Calling back source') | |
| 193 | const resp = await fetch(`${instanceUrl}/api/multiwriter/add-writer`, { | |
| 194 | method: 'POST', | |
| 195 | headers: { 'Content-Type': 'application/json' }, | |
| 196 | body: JSON.stringify({ writerKey: localWriterKey, callbackToken }) | |
| 197 | }) | |
| 198 | if (!resp.ok) { | |
| 199 | console.error('[Multiwriter] Callback failed:', resp.status) | |
| 200 | } else { | |
| 201 | console.log('[Multiwriter] Callback succeeded') | |
| 202 | } | |
| 203 | } catch (err) { | |
| 204 | console.error('[Multiwriter] Callback to source failed:', err.message) | |
| 205 | // Not fatal — source can add manually later | |
| 206 | } | |
| 207 | } | |
| 194 | // 8. Admission is handled during pairing: our writer key is stable | |
| 195 | // (bootstrap-independent) and was sent as signed userData in the handshake, | |
| 196 | // so the source already admitted us over the authenticated channel. The old | |
| 197 | // unauthenticated HTTP callback (bearer token → admit ANY key) is removed. | |
| 198 | void localWriterKey | |
| 199 | void callbackToken | |
| 208 | 200 | |
| 209 | 201 | // 9. Announce shared registry on swarm for discovery |
| 210 | 202 | if (this._swarm && this._registry.discoveryKey) { |
| … | … | @@ -107,18 +107,10 @@ export function registerMultiwriterRoutes (router, server) { |
| 107 | 107 | return |
| 108 | 108 | } |
| 109 | 109 | |
| 110 | // Authenticate: either a valid callback token (inter-instance) or admin auth | |
| 111 | if (body.callbackToken) { | |
| 112 | try { | |
| 113 | await server.multiwriter.addWriter(body.writerKey, { callbackToken: body.callbackToken }) | |
| 114 | json(res, { success: true, writerKey: body.writerKey }) | |
| 115 | return | |
| 116 | } catch (err) { | |
| 117 | // Token invalid/expired — fall through to admin auth | |
| 118 | console.log('[Multiwriter] Callback token failed, trying admin auth:', err.message) | |
| 119 | } | |
| 120 | } | |
| 121 | // Fallback: require admin auth | |
| 110 | // Inter-instance admission now rides the authenticated blind-pairing | |
| 111 | // handshake (see InstanceLinker — the joiner's stable writer key is admitted | |
| 112 | // during pairing). This HTTP endpoint is ADMIN-ONLY; the old unauthenticated | |
| 113 | // callback-token path (a bearer token that admitted ANY writerKey) is gone. | |
| 122 | 114 | try { |
| 123 | 115 | await requireAuth(req, server, 'admin') |
| 124 | 116 | await server.multiwriter.addWriter(body.writerKey) |
| … | … | @@ -0,0 +1,47 @@ |
| 1 | import test from 'brittle' | |
| 2 | import fs from 'bare-fs' | |
| 3 | import path from 'bare-path' | |
| 4 | import os from 'bare-os' | |
| 5 | import b4a from 'b4a' | |
| 6 | import Corestore from 'corestore' | |
| 7 | import crypto from 'hypercore-crypto' | |
| 8 | import { MultiwriterManager } from '../../lib/multiwriter/manager.js' | |
| 9 | import { SharedRegistry } from '../../lib/multiwriter/shared-registry.js' | |
| 10 | ||
| 11 | function tmpStore () { | |
| 12 | const dir = path.join(os.tmpdir(), 'hn-mwa-' + Date.now() + '-' + Math.random().toString(36).slice(2)) | |
| 13 | fs.mkdirSync(dir, { recursive: true }) | |
| 14 | return { store: new Corestore(dir), dir } | |
| 15 | } | |
| 16 | function rmrf (dir) { | |
| 17 | try { | |
| 18 | for (const f of fs.readdirSync(dir)) { | |
| 19 | const fp = path.join(dir, f) | |
| 20 | if (fs.statSync(fp).isDirectory()) rmrf(fp) | |
| 21 | else fs.unlinkSync(fp) | |
| 22 | } | |
| 23 | fs.rmdirSync(dir) | |
| 24 | } catch {} | |
| 25 | } | |
| 26 | ||
| 27 | test('MultiwriterManager - threads a stable, bootstrap-independent writer key', async (t) => { | |
| 28 | const a = tmpStore() | |
| 29 | const m = new MultiwriterManager(a.store) | |
| 30 | await m.ready() | |
| 31 | const kManager = m.registry.localWriterKey | |
| 32 | ||
| 33 | // The manager derives its writer keypair from the corestore primary key. | |
| 34 | // Re-deriving it and using it under a DIFFERENT bootstrap must yield the SAME | |
| 35 | // writer key — i.e. our key is knowable before pairing (the whole basis for | |
| 36 | // admitting over the authenticated handshake instead of an HTTP token). | |
| 37 | const kp = await a.store.createKeyPair('multiwriter-writer') | |
| 38 | const b = tmpStore() | |
| 39 | const ref = new SharedRegistry(b.store, { keyPair: kp, bootstrapKey: crypto.keyPair().publicKey }) | |
| 40 | await ref.ready() | |
| 41 | ||
| 42 | t.ok(b4a.equals(kManager, ref.localWriterKey), 'manager writer key is the stable key, independent of bootstrap') | |
| 43 | ||
| 44 | if (m.close) await m.close() | |
| 45 | await ref.close() | |
| 46 | rmrf(a.dir); rmrf(b.dir) | |
| 47 | }) |