OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note commits / c9508d7

multiwriter: admit writers over the authenticated pairing handshake (drop token)
Completes the writer-admission hardening. Writer admission no longer rides an unauthenticated HTTP callback bearer token (which admitted ANY writerKey to any token-bearer); it now rides the blind-pairing attestation channel, Keet-style. - manager: derive a stable per-instance writer keypair from the corestore primary key (store.createKeyPair) and thread it into BOTH SharedRegistry constructions (initial + post-join re-init). The local writer key is now bootstrap-independent and identical before/after re-init. - instance-linker: the joiner sends that stable writer key as signed userData in the handshake; the inviter admits it in onadd, bound to the paired identity. - manager join: the HTTP callback to the source is removed (admission already happened during pairing). - route POST /api/multiwriter/add-writer: the callback-token branch is removed — the endpoint is now ADMIN-ONLY. (The now-unreachable token machinery in manager.addWriter is dead code; cleanup tracked separately.) Full two-instance pairing remains an integration-test follow-up (needs testnet); unit-tested here: the manager's writer key is the stable bootstrap-independent key (1/1), built on the SharedRegistry keyPair foundation (2/2). No regression in shared-read-access (4/4). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
c9508d78bacd5e4f14ea1c183cee23ae14445397
by plankatron <fleeky@gmail.com> · 2026-06-23T22:58:06.000Z

Changed files (4) +78 -37

lib/multiwriter/instance-linker.js +14 -4
……@@ -106,8 +106,14 @@ export class InstanceLinker {
106106 }
107107 })
108108
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 }
111117 resolve(joinerInfo)
112118 } catch (err) {
113119 reject(err)
……@@ -134,9 +140,13 @@ export class InstanceLinker {
134140 async joinWithInvite (inviteHex) {
135141 const inviteBuf = Buffer.from(inviteHex, 'hex')
136142
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.
138147 const joinerInfo = {
139 instanceKey: this._instanceKey
148 instanceKey: this._instanceKey,
149 writerKey: this._registry.localWriterKey.toString('hex')
140150 }
141151 const joinerDataBuf = Buffer.from(JSON.stringify(joinerInfo), 'utf-8')
142152
lib/multiwriter/manager.js +13 -21
……@@ -53,8 +53,13 @@ export class MultiwriterManager {
5353 async ready () {
5454 if (this._ready) return
5555
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
5661 // Initialize shared registry
57 const regOpts = {}
62 const regOpts = { keyPair: this._writerKeyPair }
5863 if (this._registryBootstrapKey) {
5964 regOpts.bootstrapKey = this._registryBootstrapKey
6065 }
……@@ -151,7 +156,7 @@ export class MultiwriterManager {
151156 // 2. Create NEW SharedRegistry with source's bootstrapKey
152157 const bootstrapBuf = Buffer.isBuffer(bootstrapKey)
153158 ? 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 })
155160 await this._registry.ready()
156161
157162 // 3. Get our new localWriterKey from the SHARED registry
……@@ -186,25 +191,12 @@ export class MultiwriterManager {
186191 }
187192 }
188193
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
208200
209201 // 9. Announce shared registry on swarm for discovery
210202 if (this._swarm && this._registry.discoveryKey) {
lib/routes/multiwriter.js +4 -12
……@@ -107,18 +107,10 @@ export function registerMultiwriterRoutes (router, server) {
107107 return
108108 }
109109
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.
122114 try {
123115 await requireAuth(req, server, 'admin')
124116 await server.multiwriter.addWriter(body.writerKey)
test/unit/multiwriter-admission.test.js +47 -0
……@@ -0,0 +1,47 @@
1import test from 'brittle'
2import fs from 'bare-fs'
3import path from 'bare-path'
4import os from 'bare-os'
5import b4a from 'b4a'
6import Corestore from 'corestore'
7import crypto from 'hypercore-crypto'
8import { MultiwriterManager } from '../../lib/multiwriter/manager.js'
9import { SharedRegistry } from '../../lib/multiwriter/shared-registry.js'
10
11function 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}
16function 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
27test('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})