OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / nostr-bind.cjs

173 lines · javascript ref:
// NOSTR2 — cross-curve identity binding (pear-nostr-bind). A MUTUAL attestation
// that links a user's Ed25519 pear root ↔ their secp256k1 Nostr key: the root
// signs "this nostr key is mine" (ed25519) AND the nostr key signs "this pear
// root is mine" (BIP-340 schnorr). Both signatures are required, so NEITHER side
// can be claimed without that side's secret — you can't bind someone else's nostr
// key to your root, and a nostr key can't claim a pear root it doesn't control.
//
// Monotonic `epoch` + root-signed revocation (you can unilaterally unlink from
// your pear side); resolve is revoke-wins by current epoch so stale lower-epoch
// bindings cannot resurrect after a newer revocation. PURE, CommonJS.

const secp = require('./secp256k1-bundle.cjs') // BIP-340 schnorr (nostr side)
const crypto = require('hypercore-crypto') // ed25519 (root side)
const b4a = require('b4a')

const BIND_TAG = 'pear.nostr-bind.v1:'
const REVOKE_TAG = 'pear.nostr-bind.revoke.v1:'
const HEX64 = /^[0-9a-f]{64}$/i
const HEX128 = /^[0-9a-f]{128}$/i // ed25519 + schnorr sigs are both 64 bytes

// canonical bytes both curves sign (sorted keys, no clock)
function canonBind (rootPubkey, nostrPubkey, epoch) {
  return BIND_TAG + JSON.stringify({ e: epoch, n: nostrPubkey, r: rootPubkey }, ['e', 'n', 'r'])
}
function canonRevoke (rootPubkey, nostrPubkey, epoch) {
  return REVOKE_TAG + JSON.stringify({ e: epoch, n: nostrPubkey, r: rootPubkey }, ['e', 'n', 'r'])
}
function ed25519Verify (msg, sigHex, pubHex) {
  try { return crypto.verify(b4a.from(msg, 'utf-8'), b4a.from(String(sigHex), 'hex'), b4a.from(String(pubHex), 'hex')) } catch { return false }
}

// rootSign(msgStr) -> ed25519 sigHex (identity.sign); nostrSign(msg32Hex) ->
// schnorr sigHex (identity.nostrSign, over sha256(message)).
function makeNostrBind ({ rootPubkey, nostrPubkey, epoch }, rootSign, nostrSign) {
  if (!Number.isInteger(epoch) || epoch < 1) throw new Error('epoch must be a positive integer')
  const canon = canonBind(rootPubkey, nostrPubkey, epoch)
  return {
    kind: 'nostr-bind', v: 1, rootPubkey, nostrPubkey, epoch,
    rootSig: rootSign(canon), // ed25519: "this nostr key is mine"
    nostrSig: nostrSign(secp.sha256Hex(canon)), // secp256k1: "this pear root is mine"
  }
}

// BOTH signatures must verify: the ed25519 root sig over the canonical bytes AND
// the secp256k1 schnorr sig over sha256(those bytes), against the keys IN the
// record (root checked against the Contacts-held expected root).
function verifyNostrBind (bind, expectedRootPubkey) {
  if (!bind || bind.kind !== 'nostr-bind') return false
  if (!Number.isInteger(bind.epoch) || bind.epoch < 1) return false
  if (typeof bind.nostrPubkey !== 'string' || !HEX64.test(bind.nostrPubkey)) return false
  if (typeof bind.rootPubkey !== 'string' || !HEX64.test(bind.rootPubkey) || bind.rootPubkey !== expectedRootPubkey) return false
  // validate sig FORMAT before verify (explicit fail-closed; don't lean on the
  // verifier's try-catch to absorb malformed hex) — NOSTR2 review hardening.
  if (typeof bind.rootSig !== 'string' || !HEX128.test(bind.rootSig)) return false
  if (typeof bind.nostrSig !== 'string' || !HEX128.test(bind.nostrSig)) return false
  const canon = canonBind(bind.rootPubkey, bind.nostrPubkey, bind.epoch)
  if (!ed25519Verify(canon, bind.rootSig, expectedRootPubkey)) return false // root attests
  return secp.schnorrVerify(bind.nostrSig, secp.sha256Hex(canon), bind.nostrPubkey) // nostr attests
}

// Root-signed revocation — the pear-identity owner unilaterally unlinks.
function makeNostrRevoke ({ rootPubkey, nostrPubkey, epoch }, rootSign) {
  if (!Number.isInteger(epoch) || epoch < 1) throw new Error('epoch must be a positive integer')
  return { kind: 'nostr-revoke', v: 1, rootPubkey, nostrPubkey, epoch, rootSig: rootSign(canonRevoke(rootPubkey, nostrPubkey, epoch)) }
}
function verifyNostrRevoke (rev, expectedRootPubkey) {
  if (!rev || rev.kind !== 'nostr-revoke') return false
  if (!Number.isInteger(rev.epoch) || rev.epoch < 1) return false
  if (typeof rev.nostrPubkey !== 'string' || !HEX64.test(rev.nostrPubkey)) return false
  if (typeof rev.rootPubkey !== 'string' || !HEX64.test(rev.rootPubkey) || rev.rootPubkey !== expectedRootPubkey) return false
  if (typeof rev.rootSig !== 'string' || !HEX128.test(rev.rootSig)) return false
  return ed25519Verify(canonRevoke(rev.rootPubkey, rev.nostrPubkey, rev.epoch), rev.rootSig, expectedRootPubkey)
}

const bindId = (epoch, nostrPubkey) => epoch + ':' + String(nostrPubkey || '').toLowerCase()

function validNostrBinds (expectedRootPubkey, binds) {
  const out = []
  for (const b of binds || []) {
    if (verifyNostrBind(b, expectedRootPubkey)) out.push({ ...b, nostrPubkey: b.nostrPubkey.toLowerCase(), rootPubkey: b.rootPubkey.toLowerCase() })
  }
  return out
}

function validNostrRevocations (expectedRootPubkey, revocations) {
  const out = []
  for (const r of revocations || []) {
    if (verifyNostrRevoke(r, expectedRootPubkey)) out.push({ ...r, nostrPubkey: r.nostrPubkey.toLowerCase(), rootPubkey: r.rootPubkey.toLowerCase() })
  }
  return out
}

function newestBinding (bindings) {
  let best = null
  for (const b of bindings || []) {
    if (!best || b.epoch > best.epoch || (b.epoch === best.epoch && b.nostrPubkey < best.nostrPubkey)) best = b
  }
  return best
}

// Rich binding state for a root. `status` is the root's current advertised
// author state; `bindings[]` additionally labels every known author key so a
// consumer can distinguish old/stale notes from explicitly revoked notes.
function resolveNostrBindState (expectedRootPubkey, binds, revocations) {
  const rootPubkey = typeof expectedRootPubkey === 'string' ? expectedRootPubkey.toLowerCase() : null
  if (!rootPubkey || !HEX64.test(rootPubkey)) {
    return { status: 'unverified', rootPubkey, nostrPubkey: null, epoch: 0, binding: null, revocation: null, bindings: [], revocations: [] }
  }
  const validBinds = validNostrBinds(rootPubkey, binds)
  const validRevs = validNostrRevocations(rootPubkey, revocations)
  const revById = new Map()
  for (const r of validRevs) revById.set(bindId(r.epoch, r.nostrPubkey), r)

  const current = newestBinding(validBinds)
  const currentId = current ? bindId(current.epoch, current.nostrPubkey) : null
  const currentRev = currentId ? revById.get(currentId) || null : null
  const states = []
  const seen = new Set()
  for (const b of validBinds) {
    const id = bindId(b.epoch, b.nostrPubkey)
    const rev = revById.get(id) || null
    let status = 'stale'
    if (id === currentId) status = rev ? 'revoked' : 'linked'
    else if (rev) status = 'revoked'
    states.push({ status, rootPubkey, nostrPubkey: b.nostrPubkey, epoch: b.epoch, binding: b, revocation: rev })
    seen.add(id)
  }
  for (const r of validRevs) {
    const id = bindId(r.epoch, r.nostrPubkey)
    if (seen.has(id)) continue
    states.push({ status: 'revoked', rootPubkey, nostrPubkey: r.nostrPubkey, epoch: r.epoch, binding: null, revocation: r })
  }

  if (current) {
    return {
      status: currentRev ? 'revoked' : 'linked',
      rootPubkey,
      nostrPubkey: current.nostrPubkey,
      epoch: current.epoch,
      binding: current,
      revocation: currentRev,
      bindings: states,
      revocations: validRevs,
    }
  }

  const latestRev = newestBinding(validRevs.map((r) => ({ ...r, binding: null })))
  return {
    status: latestRev ? 'revoked' : 'unverified',
    rootPubkey,
    nostrPubkey: latestRev ? latestRev.nostrPubkey : null,
    epoch: latestRev ? latestRev.epoch : 0,
    binding: null,
    revocation: latestRev || null,
    bindings: states,
    revocations: validRevs,
  }
}

// Current attested nostr pubkey for a root, or null. Uses the richer state model
// so an explicitly revoked current binding cannot fall back to a stale older bind.
function resolveNostrBind (expectedRootPubkey, binds, revocations) {
  const state = resolveNostrBindState(expectedRootPubkey, binds, revocations)
  return state.status === 'linked' ? state.nostrPubkey : null
}

module.exports = {
  canonBind, canonRevoke,
  makeNostrBind, verifyNostrBind,
  makeNostrRevoke, verifyNostrRevoke,
  resolveNostrBind, resolveNostrBindState,
}