OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / test / nostr-binding-store.test.js

124 lines · javascript ref:
// NostrBindingStore — the live persistence half of NOSTR2. Uses a REAL
// PersonalIndex over a temp Corestore + a stub identity that mirrors identity.js
// crypto EXACTLY (ed25519 root via hypercore-crypto, secp256k1 nostr via the
// bundle, canonical bytes via nostr-bind.cjs) — identity.js itself is Bare-only.
import test from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import Corestore from 'corestore'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import secpMod from '../backend/secp256k1-bundle.cjs'
import nbMod from '../backend/nostr-bind.cjs'
import piMod from '../backend/personal-index.cjs'
import storeMod from '../backend/nostr-binding-store.cjs'
const secp = secpMod; const nb = nbMod
const { PersonalIndex } = piMod
const { NostrBindingStore } = storeMod

const hex = (b) => b4a.toString(b, 'hex')

// faithful stub of the methods NostrBindingStore calls on identity (mirrors
// backend/identity.js makeNostrBinding / makeNostrRevocation / verifyNostrBinding).
function makeStubIdentity (nostrSk = 'ab'.repeat(32)) {
  const rootKp = crypto.keyPair()
  const rootHex = hex(rootKp.publicKey)
  const npk = secp.schnorrGetPublicKey(nostrSk)
  const sign = (msg) => ({ signature: hex(crypto.sign(b4a.from(msg, 'utf-8'), rootKp.secretKey)) })
  return {
    rootHex, npk, rootKp,
    getSigningKeypair: () => rootKp,
    getNostrPublicKey: () => npk,
    sign,
    nostrSign: (msg32Hex) => secp.schnorrSign(msg32Hex, nostrSk),
    makeNostrBinding: (epoch) => nb.makeNostrBind(
      { rootPubkey: rootHex, nostrPubkey: npk, epoch },
      (msg) => sign(msg).signature,
      (msg32Hex) => secp.schnorrSign(msg32Hex, nostrSk)
    ),
    verifyNostrBinding: (bind, expectedRoot) => nb.verifyNostrBind(bind, expectedRoot),
    makeNostrRevocation: (epoch) => nb.makeNostrRevoke(
      { rootPubkey: rootHex, nostrPubkey: npk, epoch },
      (msg) => sign(msg).signature
    ),
  }
}

async function withStore (fn) {
  const dir = await mkdtemp(join(tmpdir(), 'nbs-'))
  const cs = new Corestore(dir)
  await cs.ready()
  const pi = await new PersonalIndex(cs).ready()
  const id = makeStubIdentity()
  const store = await new NostrBindingStore({ identity: id, personalIndex: pi }).ready()
  try { await fn({ store, id, pi, cs, dir }) }
  finally { await pi.close(); await cs.close(); await rm(dir, { recursive: true, force: true }) }
}

test('initial state: npub present, not linked, epoch 0', () => withStore(async ({ store, id }) => {
  const st = await store.getState()
  assert.equal(st.nostrPubkey, id.npk)
  assert.ok(st.npub.startsWith('npub1'))
  assert.equal(st.rootPubkey, id.rootHex)
  assert.equal(st.epoch, 0)
  assert.equal(st.status, 'unverified')
  assert.equal(st.linked, false)
  assert.equal(st.revoked, false)
  assert.equal(st.stale, false)
  assert.equal(st.binding, null)
  assert.deepEqual(st.revocations, [])
}))

test('bind links at epoch 1; the stored binding verifies against the root', () => withStore(async ({ store, id }) => {
  const st = await store.bind()
  assert.equal(st.status, 'linked')
  assert.equal(st.linked, true)
  assert.equal(st.epoch, 1)
  assert.equal(st.binding.epoch, 1)
  assert.equal(id.verifyNostrBinding(st.binding, id.rootHex), true)
}))

test('state persists across a fresh store instance (survives restart)', () => withStore(async ({ store, id, pi }) => {
  await store.bind()
  const reopened = await new NostrBindingStore({ identity: id, personalIndex: pi }).ready()
  const st = await reopened.getState()
  assert.equal(st.linked, true)
  assert.equal(st.epoch, 1)
}))

test('revoke unlinks; re-bind mints a higher epoch that re-links', () => withStore(async ({ store }) => {
  await store.bind() // epoch 1, linked
  let st = await store.revoke()
  assert.equal(st.status, 'revoked')
  assert.equal(st.linked, false)
  assert.equal(st.revoked, true)
  assert.equal(st.epoch, 1) // epoch counter unchanged by revoke
  assert.equal(st.revocations.length, 1)

  st = await store.bind() // epoch 2 — higher than the epoch-1 revoke → re-links
  assert.equal(st.status, 'linked')
  assert.equal(st.linked, true)
  assert.equal(st.epoch, 2)
}))

test('revoke is idempotent when there is nothing bound', () => withStore(async ({ store }) => {
  const st = await store.revoke()
  assert.equal(st.status, 'unverified')
  assert.equal(st.linked, false)
  assert.equal(st.epoch, 0)
}))

test('a FORGED revocation in the meta store is ignored (fail-closed) → stays linked', () => withStore(async ({ store, id, pi }) => {
  const st = await store.bind() // epoch 1, linked
  // an attacker writes a revocation for epoch 1 signed by a DIFFERENT root
  const attacker = makeStubIdentity()
  const forgedRev = attacker.makeNostrRevocation(1) // root-signed by the wrong key, wrong nostrPubkey too
  await pi.putMeta('nostrRevoke!1', forgedRev)
  const after = await store.getState()
  assert.equal(after.status, 'linked')
  assert.equal(after.linked, true, 'forged revocation must not unlink')
  assert.equal(after.epoch, 1)
}))