OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / nostr-binding-store.cjs

115 lines · javascript ref:
// NostrBindingStore — the live half of NOSTR2. nostr-bind.cjs is PURE (it
// neither persists nor bumps epochs); identity.makeNostrBinding/makeNostrRevocation
// mint records but store nothing. This module persists the user's current
// cross-curve binding + a monotonic epoch + revocations in the PersonalIndex
// `meta!` namespace, EXACTLY mirroring IdentityBindingPublisher (the Lighthouse
// search-key binding). Every dependency is injected → Node-testable with a real
// Corestore-backed PersonalIndex + the real Identity.
//
// The Nostr key is seed-derived and STABLE, so re-binding after a revoke mints a
// HIGHER epoch that re-links (resolveNostrBind: highest-epoch non-revoked wins).
//
// SECURITY (threat #10): bind()/revoke() take NO caller payload — the signed
// canonical bytes are constructed backend-side by identity.makeNostrBinding /
// makeNostrRevocation. The renderer can trigger a bind/revoke but can NEVER get
// the root key to sign attacker-chosen bytes through this path.

const nb = require('./nostr-bind.cjs')
const nip19 = require('./nip19.cjs')
const b4a = require('b4a')

const BINDING_META = 'nostrBinding'
const EPOCH_META = 'nostrBindingEpoch'
const revokeKey = (epoch) => 'nostrRevoke!' + epoch

class NostrBindingStore {
  constructor ({ identity, personalIndex, log } = {}) {
    if (!identity) throw new Error('NostrBindingStore requires identity')
    if (!personalIndex) throw new Error('NostrBindingStore requires personalIndex')
    this.identity = identity
    this.personalIndex = personalIndex
    this.log = typeof log === 'function' ? log : () => {}
  }

  async ready () {
    if (!this.personalIndex.bee) throw new Error('personalIndex not ready (no bee)')
    return this
  }

  _rootPubkeyHex () {
    return b4a.toString(this.identity.getSigningKeypair().publicKey, 'hex')
  }

  async _revocationsForRoot (rootHex, maxEpoch) {
    const out = []
    const end = Number.isInteger(maxEpoch) && maxEpoch > 0 ? maxEpoch : 0
    for (let epoch = 1; epoch <= end; epoch++) {
      const rev = await this.personalIndex.getMeta(revokeKey(epoch), null)
      if (rev && nb.verifyNostrRevoke(rev, rootHex)) out.push(rev)
    }
    return out
  }

  async getRevocations () {
    const rootHex = this._rootPubkeyHex()
    const epoch = await this.personalIndex.getMeta(EPOCH_META, 0)
    return this._revocationsForRoot(rootHex, epoch)
  }

  // Current public state for the UI: npub, the attested epoch, and whether the
  // binding is linked/revoked/stale/unverified. Resolved through the audited
  // state resolver so the UI and wire semantics agree.
  async getState () {
    const rootHex = this._rootPubkeyHex()
    const nostrPubkey = this.identity.getNostrPublicKey()
    const binding = await this.personalIndex.getMeta(BINDING_META, null)
    const epoch = await this.personalIndex.getMeta(EPOCH_META, 0)
    const revocations = await this._revocationsForRoot(rootHex, epoch)
    const resolved = nb.resolveNostrBindState(rootHex, binding ? [binding] : [], revocations)
    let status = resolved.status
    if (status === 'linked' && resolved.nostrPubkey !== nostrPubkey) status = 'stale'
    return {
      nostrPubkey,
      npub: nip19.npubEncode(nostrPubkey),
      rootPubkey: rootHex,
      epoch,
      status,
      linked: status === 'linked',
      revoked: status === 'revoked',
      stale: status === 'stale',
      binding: binding || null,
      revocation: resolved.revocation || null,
      revocations,
    }
  }

  // Mint + persist a binding at epoch+1 (monotonic). Self-verifies before
  // persisting — fail closed: a bad signer wiring must never be stored.
  async bind () {
    const rootHex = this._rootPubkeyHex()
    const epoch = (await this.personalIndex.getMeta(EPOCH_META, 0)) + 1
    const binding = this.identity.makeNostrBinding(epoch)
    if (!this.identity.verifyNostrBinding(binding, rootHex)) {
      throw new Error('nostr binding self-verify failed — root/nostr signer wiring is wrong')
    }
    await this.personalIndex.putMeta(BINDING_META, binding)
    await this.personalIndex.putMeta('nostrBinding!' + epoch, binding)
    await this.personalIndex.putMeta(EPOCH_META, epoch)
    this.log('[nostr-bind] linked epoch ' + epoch + ' npub=' + binding.nostrPubkey.slice(0, 12) + '…')
    return this.getState()
  }

  // Root-signed revocation of the current binding's epoch → unlinks. Idempotent
  // when there is nothing to revoke.
  async revoke () {
    const binding = await this.personalIndex.getMeta(BINDING_META, null)
    if (!binding) return this.getState()
    const rev = this.identity.makeNostrRevocation(binding.epoch)
    await this.personalIndex.putMeta(revokeKey(binding.epoch), rev)
    this.log('[nostr-bind] revoked epoch ' + binding.epoch)
    return this.getState()
  }
}

module.exports = { NostrBindingStore }