// Doorbell: proof-gated optimistic writer admission (keet's shape, using the
// writer set as the authorization record). A peer that ROTATED its key (e.g.
// forceResync re-join into a fresh corestore) requests admission of its NEW key
// by VOUCHING with its OLD, still-admitted writer key. apply admits ONLY if the
// vouch signature verifies AND the voucher is a current writer — and only ever as
// a PLAIN writer. These tests lock the security gate.
import test from 'brittle'
import Corestore from 'corestore'
import { mkdtempSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import { replicateAndSync } from 'autobase-test-helpers'
import { Room, jsonSchema, signWriterVouch } from '../index.js'
function tmp () { return mkdtempSync(join(tmpdir(), 'rooms-doorbell-')) }
const schema = jsonSchema({ validate () {}, async apply () {} })
async function settle (rooms, rounds = 50) {
const bases = rooms.map((r) => r.base)
for (let i = 0; i < rounds; i++) {
for (const b of bases) { try { await b.ack() } catch {} }
await replicateAndSync(bases)
}
}
const hasWriter = async (room, keyHex) => (await room.listWriters()).some((w) => w.writerKey === keyHex)
// Genesis room G + an existing writer A (the voucher). Returns A's rotation:
// a NEW read-only room A2 in a fresh corestore, and A_OLD's secret key.
async function setup (dir) {
const G = new Room(new Corestore(join(dir, 'G')), { schema, namespace: 'ns' }); await G.ready()
const A = new Room(new Corestore(join(dir, 'A')), { schema, namespace: 'ns', bootstrapKey: G.key }); await A.ready()
await settle([G, A])
const aOld = A.localKeyHex
await G.addWriter('A', { writerKey: aOld, role: 'writer' })
await settle([G, A])
const voucherSecret = A.base.local.keyPair.secretKey
const voucherSignerKey = b4a.toString(A.base.local.keyPair.publicKey, 'hex')
const A2 = new Room(new Corestore(join(dir, 'A2')), { schema, namespace: 'ns', bootstrapKey: G.key }); await A2.ready()
await settle([G, A, A2])
return { G, A, A2, aOld, aNew: A2.localKeyHex, voucherSecret, voucherSignerKey }
}
test('doorbell: valid vouch by an existing writer admits the rotated key as a PLAIN writer', async (t) => {
const dir = tmp()
const { G, A, A2, aOld, aNew, voucherSecret, voucherSignerKey } = await setup(dir)
t.ok(await hasWriter(G, aOld), 'A_OLD is the existing writer (voucher)')
t.absent(A2.base.writable, 'A_NEW is read-only before the doorbell')
const voucherSig = signWriterVouch(aNew, voucherSecret)
await A2.requestWriter({ newWriterKey: aNew, voucherSignerKey, voucherSig, userId: 'A-rotated' })
await settle([G, A, A2])
t.ok(await hasWriter(G, aNew), 'A_NEW admitted via the doorbell')
const w = (await G.listWriters()).find((x) => x.writerKey === aNew)
t.is(w.role, 'writer', 'admitted as PLAIN writer (never indexer/owner)')
t.ok(A2.base.writable, 'A_NEW is now writable — participation restored via the doorbell')
t.teardown(() => Promise.all([G.close(), A.close(), A2.close()]).catch(() => {}))
})
test('doorbell: a BAD signature admits nothing', async (t) => {
const dir = tmp()
const { G, A, A2, aNew, voucherSignerKey } = await setup(dir)
const badSig = b4a.toString(crypto.randomBytes(64), 'hex')
await A2.requestWriter({ newWriterKey: aNew, voucherSignerKey, voucherSig: badSig, userId: 'A-rotated' })
await settle([G, A, A2])
t.absent(await hasWriter(G, aNew), 'forged vouch admits nothing')
t.absent(A2.base.writable, 'A_NEW stays read-only')
t.teardown(() => Promise.all([G.close(), A.close(), A2.close()]).catch(() => {}))
})
test('doorbell: a signer that is NOT a current writer admits nothing (even with a valid sig)', async (t) => {
const dir = tmp()
const { G, A, A2, aNew } = await setup(dir)
// A random keypair that was NEVER a writer — sign correctly with it. The sig
// verifies, but its recomputed manifest key isn't in the writer set → reject.
const stranger = crypto.keyPair()
const strangerSignerKey = b4a.toString(stranger.publicKey, 'hex')
const voucherSig = signWriterVouch(aNew, stranger.secretKey)
await A2.requestWriter({ newWriterKey: aNew, voucherSignerKey: strangerSignerKey, voucherSig, userId: 'A-rotated' })
await settle([G, A, A2])
t.absent(await hasWriter(G, aNew), 'a valid sig by a non-writer admits nothing')
t.absent(A2.base.writable, 'A_NEW stays read-only')
t.teardown(() => Promise.all([G.close(), A.close(), A2.close()]).catch(() => {}))
})