OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-rooms commits / 03f70c6

hyper-rooms 0.1.4 — doorbell: proof-gated optimistic writer admission
Room.requestWriter() + _applyRequestWriter + signWriterVouch: a not-yet-admitted peer requests admission of its OWN new writer key by VOUCHING with an existing writer key it controls (keet's proof-gated-optimistic shape, using the writer set as the authorization record). For key ROTATION by an existing writer (e.g. forceResync re-join into a fresh corestore). apply admits ONLY if: 1. the vouch sig verifies against the raw signer publicKey, AND 2. the signer's recomputed manifest key (Hypercore.key single-signer) is a CURRENT non-mirror writer. Always a PLAIN writer (no self-escalation to indexer/owner). autobase writer keys are MANIFEST keys, not raw signing keys — so the vouch carries the raw signer publicKey and apply recomputes the manifest key to bind it back to a writer (verified: Hypercore.key({signers:[{publicKey}]}) == local.key). Optimistic append so a not-yet-writable peer can ring it; content-free (proof only) so nothing is lost if no indexer admits. Test doorbell 3/3: valid vouch admits (plain writer), forged sig admits nothing, non-writer signer admits nothing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
03f70c6a09548433a78692df12cd114edfb13c84
by plankatron <fleeky@gmail.com> · 2026-07-20T02:57:09.000Z

Changed files (4) +205 -2

index.js +1 -1
……@@ -11,7 +11,7 @@
1111 * (the canonical Keet invite-code flow; P2P, no HTTP)
1212 */
1313
14export { Room } from './lib/room.js'
14export { Room, signWriterVouch } from './lib/room.js'
1515export { jsonSchema } from './lib/json-schema.js'
1616export { composeSchemas } from './lib/compose-schemas.js'
1717export { encodeInvite, decodeInvite, newSeed, isExpired, ROLES } from './lib/invitations.js'
lib/room.js +112 -0
……@@ -47,8 +47,21 @@
4747
4848import Autobase from 'autobase'
4949import Hyperbee from 'hyperbee'
50import Hypercore from 'hypercore'
5051import ReadyResource from 'ready-resource'
5152import b4a from 'b4a'
53import crypto from 'hypercore-crypto'
54
55/**
56 * Sign a writer-rotation vouch: the voucher (an existing writer) signs the NEW
57 * writer key, authorizing its admission via the doorbell (see Room.requestWriter
58 * / _applyRequestWriter). Returns the signature hex.
59 * @param {string} newWriterKeyHex - the new local writer key being vouched for
60 * @param {Buffer} voucherSecretKey - the existing writer's hypercore secret key
61 */
62export function signWriterVouch (newWriterKeyHex, voucherSecretKey) {
63 return b4a.toString(crypto.sign(b4a.from(newWriterKeyHex, 'hex'), voucherSecretKey), 'hex')
64}
5265
5366export class Room extends ReadyResource {
5467 /**
……@@ -209,6 +222,46 @@ export class Room extends ReadyResource {
209222 await this._base.append(this._schema.encodeOp(op))
210223 }
211224
225 /**
226 * DOORBELL: a not-yet-admitted peer requests admission of its own new writer
227 * key, proving authorization by a VOUCH from an existing writer key (keet's
228 * proof-gated-optimistic admission, using the writer set as the authorization
229 * record). Used when an existing writer ROTATES its key (e.g. forceResync
230 * re-join into a fresh corestore): the peer signs its NEW key with its OLD
231 * (still-admitted) writer key. apply (_applyRequestWriter) admits ONLY if the
232 * vouch verifies AND the voucher is a current writer — and only ever as a
233 * PLAIN writer (never indexer/owner: no self-escalation).
234 *
235 * Appended OPTIMISTICALLY so a not-yet-writable peer can ring it; it carries
236 * only the proof (no content), so nothing is lost if no indexer admits it.
237 *
238 * autobase writer keys are MANIFEST keys, not raw signing keys, so the vouch
239 * carries the voucher's raw SIGNER publicKey; apply recomputes the manifest
240 * key from it (`Hypercore.key({signers:[{publicKey}]})`) to bind it back to a
241 * current writer.
242 *
243 * @param {object} opts
244 * @param {string} opts.newWriterKey - this peer's new local writer key (hex)
245 * @param {string} opts.voucherSignerKey - the voucher writer's raw signer publicKey (hex);
246 * from `oldRoom.base.local.keyPair.publicKey`
247 * @param {string} opts.voucherSig - signWriterVouch(newWriterKey, voucherSignerSecretKey)
248 * @param {string} [opts.userId] - writer-set label (default = newWriterKey)
249 */
250 async requestWriter (opts = {}) {
251 const { newWriterKey, voucherSignerKey, voucherSig, userId } = opts
252 if (!newWriterKey) throw new Error('requestWriter requires newWriterKey (hex)')
253 if (!voucherSignerKey || !voucherSig) throw new Error('requestWriter requires voucherSignerKey + voucherSig')
254 const op = {
255 type: 'request-writer',
256 userId: userId || newWriterKey,
257 newWriterKey,
258 voucherSignerKey,
259 voucherSig,
260 timestamp: Date.now()
261 }
262 await this._base.append(this._schema.encodeOp(op), { optimistic: true })
263 }
264
212265 /**
213266 * Append a remove-writer op for the given userId.
214267 * @param {string} userId
……@@ -271,6 +324,10 @@ export class Room extends ReadyResource {
271324 await this._applyRemoveWriter(batch, op, host)
272325 continue
273326 }
327 if (op.type === 'request-writer') {
328 await this._applyRequestWriter(view, batch, op, host)
329 continue
330 }
274331
275332 // Schema-specific ops
276333 try {
……@@ -306,6 +363,61 @@ export class Room extends ReadyResource {
306363 }
307364 }
308365
366 // DOORBELL apply (see requestWriter). Deterministic, proof-gated: admit
367 // op.newWriterKey ONLY if (1) op.voucherSig is a valid signature of
368 // op.newWriterKey by op.voucherKey, AND (2) op.voucherKey is a CURRENT
369 // non-mirror writer in this room. Authorizes a key ROTATION by an existing
370 // writer; grants a PLAIN writer only (never indexer). Silently no-ops on a
371 // bad proof — a forged/unauthorized request admits nothing.
372 async _applyRequestWriter (view, batch, op, host) {
373 const { newWriterKey, voucherSignerKey, voucherSig } = op
374 if (!newWriterKey || !voucherSignerKey || !voucherSig) return
375
376 // (1) verify the vouch signature over the new key by the raw signer key
377 let sigOk = false
378 try {
379 sigOk = crypto.verify(
380 b4a.from(newWriterKey, 'hex'),
381 b4a.from(voucherSig, 'hex'),
382 b4a.from(voucherSignerKey, 'hex')
383 )
384 } catch { sigOk = false }
385 if (!sigOk) return
386
387 // (2) bind the signer to a writer key: recompute the default single-signer
388 // MANIFEST key from the signer publicKey (autobase writer keys are manifests,
389 // not raw keys). A non-default manifest simply won't match → safe rejection.
390 let voucherKey
391 try {
392 voucherKey = b4a.toString(Hypercore.key({ signers: [{ publicKey: b4a.from(voucherSignerKey, 'hex') }] }), 'hex')
393 } catch { return }
394
395 // (3) that manifest key must be a current, non-mirror writer in this room
396 let voucherIsWriter = false
397 try {
398 for await (const entry of view.createReadStream({ gte: 'writer:', lt: 'writer:\xff' })) {
399 const v = entry.value
400 if (v && v.writerKey === voucherKey && v.role !== 'mirror') { voucherIsWriter = true; break }
401 }
402 } catch { voucherIsWriter = false }
403 if (!voucherIsWriter) return
404
405 // Admit — PLAIN writer only (no self-escalation to indexer/owner).
406 const userId = op.userId || newWriterKey
407 await batch.put(`writer:${userId}`, {
408 role: 'writer',
409 writerKey: newWriterKey,
410 addedBy: 'doorbell:' + voucherKey.slice(0, 16),
411 addedAt: op.timestamp || 0
412 })
413 try {
414 await host.addWriter(b4a.from(newWriterKey, 'hex'), { indexer: false })
415 } catch (err) {
416 // eslint-disable-next-line no-console
417 console.error('[Room] doorbell addWriter failed:', err.message)
418 }
419 }
420
309421 async _applyRemoveWriter (batch, op, host) {
310422 const existing = await batch.get(`writer:${op.userId}`)
311423 if (!existing) return
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-rooms",
3 "version": "0.1.3",
3 "version": "0.1.4",
44 "description": "Multiwriter rooms on Autobase with pluggable schemas — the substrate for hyper-issues, hyper-orgs, etc.",
55 "type": "module",
66 "main": "index.js",
test/doorbell.test.js +91 -0
……@@ -0,0 +1,91 @@
1// Doorbell: proof-gated optimistic writer admission (keet's shape, using the
2// writer set as the authorization record). A peer that ROTATED its key (e.g.
3// forceResync re-join into a fresh corestore) requests admission of its NEW key
4// by VOUCHING with its OLD, still-admitted writer key. apply admits ONLY if the
5// vouch signature verifies AND the voucher is a current writer — and only ever as
6// a PLAIN writer. These tests lock the security gate.
7import test from 'brittle'
8import Corestore from 'corestore'
9import { mkdtempSync } from 'fs'
10import { join } from 'path'
11import { tmpdir } from 'os'
12import crypto from 'hypercore-crypto'
13import b4a from 'b4a'
14import { replicateAndSync } from 'autobase-test-helpers'
15import { Room, jsonSchema, signWriterVouch } from '../index.js'
16
17function tmp () { return mkdtempSync(join(tmpdir(), 'rooms-doorbell-')) }
18const schema = jsonSchema({ validate () {}, async apply () {} })
19
20async function settle (rooms, rounds = 50) {
21 const bases = rooms.map((r) => r.base)
22 for (let i = 0; i < rounds; i++) {
23 for (const b of bases) { try { await b.ack() } catch {} }
24 await replicateAndSync(bases)
25 }
26}
27const hasWriter = async (room, keyHex) => (await room.listWriters()).some((w) => w.writerKey === keyHex)
28
29// Genesis room G + an existing writer A (the voucher). Returns A's rotation:
30// a NEW read-only room A2 in a fresh corestore, and A_OLD's secret key.
31async function setup (dir) {
32 const G = new Room(new Corestore(join(dir, 'G')), { schema, namespace: 'ns' }); await G.ready()
33 const A = new Room(new Corestore(join(dir, 'A')), { schema, namespace: 'ns', bootstrapKey: G.key }); await A.ready()
34 await settle([G, A])
35 const aOld = A.localKeyHex
36 await G.addWriter('A', { writerKey: aOld, role: 'writer' })
37 await settle([G, A])
38 const voucherSecret = A.base.local.keyPair.secretKey
39 const voucherSignerKey = b4a.toString(A.base.local.keyPair.publicKey, 'hex')
40 const A2 = new Room(new Corestore(join(dir, 'A2')), { schema, namespace: 'ns', bootstrapKey: G.key }); await A2.ready()
41 await settle([G, A, A2])
42 return { G, A, A2, aOld, aNew: A2.localKeyHex, voucherSecret, voucherSignerKey }
43}
44
45test('doorbell: valid vouch by an existing writer admits the rotated key as a PLAIN writer', async (t) => {
46 const dir = tmp()
47 const { G, A, A2, aOld, aNew, voucherSecret, voucherSignerKey } = await setup(dir)
48 t.ok(await hasWriter(G, aOld), 'A_OLD is the existing writer (voucher)')
49 t.absent(A2.base.writable, 'A_NEW is read-only before the doorbell')
50
51 const voucherSig = signWriterVouch(aNew, voucherSecret)
52 await A2.requestWriter({ newWriterKey: aNew, voucherSignerKey, voucherSig, userId: 'A-rotated' })
53 await settle([G, A, A2])
54
55 t.ok(await hasWriter(G, aNew), 'A_NEW admitted via the doorbell')
56 const w = (await G.listWriters()).find((x) => x.writerKey === aNew)
57 t.is(w.role, 'writer', 'admitted as PLAIN writer (never indexer/owner)')
58 t.ok(A2.base.writable, 'A_NEW is now writable — participation restored via the doorbell')
59
60 t.teardown(() => Promise.all([G.close(), A.close(), A2.close()]).catch(() => {}))
61})
62
63test('doorbell: a BAD signature admits nothing', async (t) => {
64 const dir = tmp()
65 const { G, A, A2, aNew, voucherSignerKey } = await setup(dir)
66 const badSig = b4a.toString(crypto.randomBytes(64), 'hex')
67 await A2.requestWriter({ newWriterKey: aNew, voucherSignerKey, voucherSig: badSig, userId: 'A-rotated' })
68 await settle([G, A, A2])
69
70 t.absent(await hasWriter(G, aNew), 'forged vouch admits nothing')
71 t.absent(A2.base.writable, 'A_NEW stays read-only')
72
73 t.teardown(() => Promise.all([G.close(), A.close(), A2.close()]).catch(() => {}))
74})
75
76test('doorbell: a signer that is NOT a current writer admits nothing (even with a valid sig)', async (t) => {
77 const dir = tmp()
78 const { G, A, A2, aNew } = await setup(dir)
79 // A random keypair that was NEVER a writer — sign correctly with it. The sig
80 // verifies, but its recomputed manifest key isn't in the writer set → reject.
81 const stranger = crypto.keyPair()
82 const strangerSignerKey = b4a.toString(stranger.publicKey, 'hex')
83 const voucherSig = signWriterVouch(aNew, stranger.secretKey)
84 await A2.requestWriter({ newWriterKey: aNew, voucherSignerKey: strangerSignerKey, voucherSig, userId: 'A-rotated' })
85 await settle([G, A, A2])
86
87 t.absent(await hasWriter(G, aNew), 'a valid sig by a non-writer admits nothing')
88 t.absent(A2.base.writable, 'A_NEW stays read-only')
89
90 t.teardown(() => Promise.all([G.close(), A.close(), A2.close()]).catch(() => {}))
91})