OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / identity-binding.cjs

156 lines · javascript ref:
// Lighthouse Phase 2 — IdentityBinding + the missing detached-verify. PURE,
// CommonJS. Closes the v1 showstopper that signForApp does NOT bind a posting's
// signer to the user's root / Contacts identity, and that there is no
// identity.verify. See docs/P2P-SEARCH-RESEARCH.md (gap: identity-binding).
//
// Two concerns:
//  1. verifyAppSig — verify a per-app (e.g. 'search') detached ed25519 signature
//     using the SAME domain-separated tag signForApp produces
//     (`pear.app.<appId>:<namespace>:`), so a signature can't be replayed
//     across apps/namespaces.
//  2. IdentityBinding — a VERSIONED, root-signed record linking your root
//     pubkey → a (rotatable, Corestore-persisted, NOT seed-derived) search
//     pubkey, plus revocation. First-use authentication requires the binding be
//     checked against the Contacts-held ROOT pubkey, never a self-asserted one.

const crypto = require('hypercore-crypto')
const b4a = require('b4a')

const HEX64 = /^[0-9a-f]{64}$/i // ed25519 root + sub-key pubkeys
const HEX128 = /^[0-9a-f]{128}$/i // ed25519 signatures

// --- per-app signature domain (mirrors identity.js signForApp exactly) -------

function appTag (appId, namespace = '') {
  return `pear.app.${appId}:${namespace}:`
}

// The exact message bytes signForApp signs: tag ‖ payload.
function appMessage (appId, payload, namespace = '') {
  const tag = b4a.from(appTag(appId, namespace), 'utf-8')
  const body = typeof payload === 'string' ? b4a.from(payload, 'utf-8') : b4a.from(payload || [])
  return b4a.concat([tag, body])
}

function verifyAppSig (appId, payload, sigHex, pubkeyHex, namespace = '') {
  if (!sigHex || !pubkeyHex) return false
  try {
    return crypto.verify(appMessage(appId, payload, namespace), b4a.from(sigHex, 'hex'), b4a.from(pubkeyHex, 'hex'))
  } catch { return false }
}

// --- IdentityBinding ---------------------------------------------------------
//
// A binding is signed by the ROOT key (so only the identity owner can issue
// one), and is monotonically versioned so the search key can ROTATE (the search
// key is a random Corestore-persisted keypair, NOT getAppKeypair('search'),
// which is seed-derived and therefore unrotatable). A revocation (same version)
// invalidates a binding.

const BINDING_TAG = 'pear.idbinding.v3:'
const REVOKE_TAG = 'pear.idbinding.revoke.v3:'

// A binding ties root → a sub-key FOR ONE PURPOSE. `purpose` is in the SIGNED
// bytes (cross-purpose replay defense): a binding minted for 'search' can never
// satisfy a 'nostr'/'merchant'/... resolve, even with a valid root signature.
// Add purposes here as tracks land; every resolver MUST pass the purpose it
// expects. (v3 cut from v2 is free — no live bindings predate it.)
const PURPOSES = ['search', 'name', 'merchant', 'nostr', 'routing']
const PURPOSE_SEARCH = 'search'

// Canonical, stable bytes a binding/revocation signs (sorted keys, no clock).
function canonBinding (rootPubkey, searchPubkey, purpose, version) {
  return BINDING_TAG + JSON.stringify({ p: purpose, r: rootPubkey, s: searchPubkey, v: version }, ['p', 'r', 's', 'v'])
}
function canonRevoke (rootPubkey, searchPubkey, purpose, version) {
  return REVOKE_TAG + JSON.stringify({ p: purpose, r: rootPubkey, s: searchPubkey, v: version }, ['p', 'r', 's', 'v'])
}

// `rootSign(msgString) -> sigHex` signs with the user's ROOT key (identity.sign
// in the app; a test stub otherwise). `searchPubkey` is the bound sub-key (named
// for the search consumer; generic across purposes).
function makeBinding ({ rootPubkey, searchPubkey, purpose, version }, rootSign) {
  if (!Number.isInteger(version) || version < 1) throw new Error('version must be a positive integer')
  if (typeof purpose !== 'string' || !purpose) throw new Error('purpose must be a non-empty string')
  const sig = rootSign(canonBinding(rootPubkey, searchPubkey, purpose, version))
  return { kind: 'binding', v: 3, rootPubkey, searchPubkey, purpose, version, sig }
}

function makeRevocation ({ rootPubkey, searchPubkey, purpose, version }, rootSign) {
  if (!Number.isInteger(version) || version < 1) throw new Error('version must be a positive integer')
  if (typeof purpose !== 'string' || !purpose) throw new Error('purpose must be a non-empty string')
  const sig = rootSign(canonRevoke(rootPubkey, searchPubkey, purpose, version))
  return { kind: 'revoke', v: 3, rootPubkey, searchPubkey, purpose, version, sig }
}

// Verify a binding's root signature against the EXPECTED root pubkey (the one
// held in Contacts for this peer — NOT binding.rootPubkey, which is attacker-
// controllable on first sight). This is the MITM defense.
// Verify a binding's root signature AND that it was minted for `expectedPurpose`.
// expectedPurpose is REQUIRED — a binding counts only for the EXACT purpose the
// consumer asks for, so a 'nostr' binding can't satisfy a 'search' resolve.
function verifyBinding (binding, expectedRootPubkey, expectedPurpose) {
  if (!binding || binding.kind !== 'binding') return false
  // version must be a canonical integer: a string "10" would verify (the sig is
  // over its JSON form) yet break resolveSearchKey's `===`/`>` version logic,
  // re-opening the equivocation the tie-break exists to close.
  if (!Number.isInteger(binding.version) || binding.version < 1) return false
  // searchPubkey must be a string too: a number/null at the same version would
  // make resolveSearchKey's `<` tie-break number-vs-string (false both ways) →
  // array-order-dependent resolution (the equivocation split-view), and a null
  // "winner" collides with the no-key sentinel.
  // searchPubkey must be a 64-hex pubkey (not just any string): an arbitrary
  // string like 'aaa' has no key behind it, and explicit format validation makes
  // a verification failure unambiguously "wrong signer", never "malformed hex".
  if (typeof binding.searchPubkey !== 'string' || !HEX64.test(binding.searchPubkey)) return false
  // purpose must be a string AND match — cross-purpose replay defense.
  if (typeof binding.purpose !== 'string' || binding.purpose !== expectedPurpose) return false
  if (typeof binding.rootPubkey !== 'string' || !HEX64.test(binding.rootPubkey) || binding.rootPubkey !== expectedRootPubkey) return false
  if (typeof binding.sig !== 'string' || !HEX128.test(binding.sig)) return false
  try {
    return crypto.verify(b4a.from(canonBinding(binding.rootPubkey, binding.searchPubkey, binding.purpose, binding.version), 'utf-8'),
      b4a.from(binding.sig, 'hex'), b4a.from(expectedRootPubkey, 'hex'))
  } catch { return false }
}

function verifyRevocation (rev, expectedRootPubkey, expectedPurpose) {
  if (!rev || rev.kind !== 'revoke') return false
  if (!Number.isInteger(rev.version) || rev.version < 1) return false
  if (typeof rev.searchPubkey !== 'string' || !HEX64.test(rev.searchPubkey)) return false
  if (typeof rev.purpose !== 'string' || rev.purpose !== expectedPurpose) return false
  if (typeof rev.rootPubkey !== 'string' || !HEX64.test(rev.rootPubkey) || rev.rootPubkey !== expectedRootPubkey) return false
  if (typeof rev.sig !== 'string' || !HEX128.test(rev.sig)) return false
  try {
    return crypto.verify(b4a.from(canonRevoke(rev.rootPubkey, rev.searchPubkey, rev.purpose, rev.version), 'utf-8'),
      b4a.from(rev.sig, 'hex'), b4a.from(expectedRootPubkey, 'hex'))
  } catch { return false }
}

// Resolve the CURRENT valid search pubkey for a root from its bindings +
// revocations, authenticated against the Contacts-held root pubkey. Highest
// non-revoked version wins. Returns the searchPubkey hex, or null.
function resolveSearchKey (expectedRootPubkey, bindings, revocations) {
  const revoked = new Set()
  for (const r of revocations || []) {
    if (verifyRevocation(r, expectedRootPubkey, PURPOSE_SEARCH)) revoked.add(r.version + ':' + r.searchPubkey)
  }
  let best = null
  for (const b of bindings || []) {
    if (!verifyBinding(b, expectedRootPubkey, PURPOSE_SEARCH)) continue
    if (revoked.has(b.version + ':' + b.searchPubkey)) continue
    // highest version wins; equal-version ties (equivocation — two valid
    // bindings at the same version) broken deterministically by searchPubkey so
    // every peer resolves the same key regardless of array order.
    if (!best || b.version > best.version ||
      (b.version === best.version && b.searchPubkey < best.searchPubkey)) best = b
  }
  return best ? best.searchPubkey : null
}

module.exports = {
  appTag, appMessage, verifyAppSig,
  canonBinding, canonRevoke,
  makeBinding, makeRevocation, verifyBinding, verifyRevocation, resolveSearchKey,
  PURPOSES, PURPOSE_SEARCH,
}