OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-rooms / test / doorbell.test.js

92 lines · javascript ref:
// 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(() => {}))
})