OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / test / lighthouse-phase2.test.js

147 lines · javascript ref:
// Phase-2 tests: identity-binding (per-app verify + versioned/revocable binding
// with first-use MITM defense) and the digest tier (Bloom + top-terms head).
import test from 'node:test'
import assert from 'node:assert/strict'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import ib from '../backend/identity-binding.cjs'
import dg from '../backend/search-digest.cjs'

const hex = (buf) => b4a.toString(buf, 'hex')
function signer (kp) { return (msg) => hex(crypto.sign(typeof msg === 'string' ? b4a.from(msg, 'utf-8') : msg, kp.secretKey)) }

// --- identity-binding: per-app signature verify --------------------------------

test('verifyAppSig validates a domain-separated per-app signature', () => {
  const kp = crypto.keyPair()
  const payload = JSON.stringify({ docId: 'abc', t: 'hi' })
  const sig = signer(kp)(ib.appMessage('search', payload, 'lighthouse-doc-v2'))
  assert.equal(ib.verifyAppSig('search', payload, sig, hex(kp.publicKey), 'lighthouse-doc-v2'), true)
  // wrong namespace / app / payload all fail (no cross-context replay)
  assert.equal(ib.verifyAppSig('search', payload, sig, hex(kp.publicKey), 'other-ns'), false)
  assert.equal(ib.verifyAppSig('mail', payload, sig, hex(kp.publicKey), 'lighthouse-doc-v2'), false)
  assert.equal(ib.verifyAppSig('search', payload + 'x', sig, hex(kp.publicKey), 'lighthouse-doc-v2'), false)
})

// --- identity-binding: versioned binding + first-use auth ----------------------

test('verifyBinding authenticates against the Contacts-held root (+ purpose)', () => {
  const root = crypto.keyPair()
  const attacker = crypto.keyPair()
  const rootHex = hex(root.publicKey)
  const search1 = hex(crypto.keyPair().publicKey)

  const binding = ib.makeBinding({ rootPubkey: rootHex, searchPubkey: search1, purpose: 'search', version: 1 }, signer(root))
  assert.equal(ib.verifyBinding(binding, rootHex, 'search'), true)

  // MITM: attacker presents a binding claiming the victim's rootPubkey but signs
  // it with their own key → fails against the Contacts-held root pubkey.
  const forged = ib.makeBinding({ rootPubkey: rootHex, searchPubkey: hex(attacker.publicKey), purpose: 'search', version: 1 }, signer(attacker))
  assert.equal(ib.verifyBinding(forged, rootHex, 'search'), false)
  // and a binding for a different root must not verify against rootHex
  const otherBinding = ib.makeBinding({ rootPubkey: hex(attacker.publicKey), searchPubkey: search1, purpose: 'search', version: 1 }, signer(attacker))
  assert.equal(ib.verifyBinding(otherBinding, rootHex, 'search'), false)
})

test('cross-purpose replay: a binding for one purpose never satisfies another', () => {
  const root = crypto.keyPair(); const rootHex = hex(root.publicKey)
  const sk = hex(crypto.keyPair().publicKey)
  // a perfectly valid NOSTR binding (right root, right signature)...
  const nostr = ib.makeBinding({ rootPubkey: rootHex, searchPubkey: sk, purpose: 'nostr', version: 1 }, signer(root))
  assert.equal(ib.verifyBinding(nostr, rootHex, 'nostr'), true)
  // ...must NOT verify as a SEARCH binding, even with the valid root signature
  assert.equal(ib.verifyBinding(nostr, rootHex, 'search'), false)
  // and resolveSearchKey (purpose 'search') ignores it entirely
  assert.equal(ib.resolveSearchKey(rootHex, [nostr], []), null)
  // makeBinding requires a purpose
  assert.throws(() => ib.makeBinding({ rootPubkey: rootHex, searchPubkey: sk, version: 1 }, signer(root)), /purpose/)
})

test('resolveSearchKey rotates to the highest non-revoked version', () => {
  const root = crypto.keyPair()
  const rootHex = hex(root.publicKey)
  const sk1 = hex(crypto.keyPair().publicKey)
  const sk2 = hex(crypto.keyPair().publicKey)
  const b1 = ib.makeBinding({ rootPubkey: rootHex, searchPubkey: sk1, purpose: 'search', version: 1 }, signer(root))
  const b2 = ib.makeBinding({ rootPubkey: rootHex, searchPubkey: sk2, purpose: 'search', version: 2 }, signer(root))

  assert.equal(ib.resolveSearchKey(rootHex, [b1, b2], []), sk2, 'highest version wins (rotation)')

  // revoke v2 → falls back to v1
  const rev = ib.makeRevocation({ rootPubkey: rootHex, searchPubkey: sk2, purpose: 'search', version: 2 }, signer(root))
  assert.equal(ib.resolveSearchKey(rootHex, [b1, b2], [rev]), sk1, 'revoked version drops out')

  // a revocation signed by the wrong key is ignored
  const attacker = crypto.keyPair()
  const fakeRev = ib.makeRevocation({ rootPubkey: rootHex, searchPubkey: sk2, purpose: 'search', version: 2 }, signer(attacker))
  assert.equal(ib.resolveSearchKey(rootHex, [b1, b2], [fakeRev]), sk2, 'forged revocation ignored')
})

test('resolveSearchKey breaks equal-version equivocation deterministically', () => {
  const root = crypto.keyPair(); const rootHex = hex(root.publicKey)
  const skA = 'aa'.repeat(32); const skB = 'bb'.repeat(32) // real 64-hex; skA < skB
  const bA = ib.makeBinding({ rootPubkey: rootHex, searchPubkey: skA, purpose: 'search', version: 2 }, signer(root))
  const bB = ib.makeBinding({ rootPubkey: rootHex, searchPubkey: skB, purpose: 'search', version: 2 }, signer(root))
  // same resolution regardless of array order; smaller searchPubkey wins
  assert.equal(ib.resolveSearchKey(rootHex, [bA, bB], []), ib.resolveSearchKey(rootHex, [bB, bA], []))
  assert.equal(ib.resolveSearchKey(rootHex, [bB, bA], []), skA)
})

test('verifyBinding rejects a non-integer (string) version and non-string searchPubkey', () => {
  const root = crypto.keyPair(); const rootHex = hex(root.publicKey)
  const cB = 'pear.idbinding.v3:' + JSON.stringify({ p: 'search', r: rootHex, s: 'sk', v: '10' }, ['p', 'r', 's', 'v'])
  const evilB = { kind: 'binding', v: 3, rootPubkey: rootHex, searchPubkey: 'sk', purpose: 'search', version: '10', sig: signer(root)(cB) }
  assert.equal(ib.verifyBinding(evilB, rootHex, 'search'), false, 'string version must not verify')
  assert.equal(ib.resolveSearchKey(rootHex, [evilB], []), null)
  const cS = 'pear.idbinding.v3:' + JSON.stringify({ p: 'search', r: rootHex, s: 5, v: 1 }, ['p', 'r', 's', 'v'])
  const evilS = { kind: 'binding', v: 3, rootPubkey: rootHex, searchPubkey: 5, purpose: 'search', version: 1, sig: signer(root)(cS) }
  assert.equal(ib.verifyBinding(evilS, rootHex, 'search'), false, 'non-string searchPubkey must not verify')
})

// --- digest tier --------------------------------------------------------------

test('malformed/hostile digest fails CLOSED (no fail-open to true)', () => {
  // a digest with k=0 must not report every docId as "probably present"
  assert.equal(dg.digestMayContainDoc({ bits: 'AAAA', m: 16, k: 0 }, 'anyid'), false)
  assert.equal(dg.digestMayContainDoc({ bits: '', m: 0, k: 1 }, 'anyid'), false)
  assert.equal(dg.digestMayContainDoc({ bits: 'AA', m: 99999, k: 3 }, 'anyid'), false) // wrong bit-length
  assert.equal(dg.digestMayContainDoc(null, 'anyid'), false)
  // a truthy NON-string bits must fail closed, not throw (DoS the verifier)
  assert.equal(dg.digestMayContainDoc({ bits: 999, m: 16, k: 3 }, 'anyid'), false)
  assert.equal(dg.digestMayContainDoc({ bits: {}, m: 16, k: 3 }, 'anyid'), false)
})

test('digest has no false negatives and bounded false positives', () => {
  const present = Array.from({ length: 2000 }, (_, i) => 'docid' + i.toString(16).padStart(16, '0'))
  const digest = dg.buildDigest(present, [], { p: 0.001 })
  for (const id of present) assert.equal(dg.digestMayContainDoc(digest, id), true) // no false negatives

  let fp = 0
  const trials = 4000
  for (let i = 0; i < trials; i++) {
    if (dg.digestMayContainDoc(digest, 'absent' + i.toString(16))) fp++
  }
  assert.ok(fp / trials < 0.02, `false-positive rate ${fp}/${trials} should be well under 2% (target 0.1%)`)
})

test('digest head keeps the highest-df terms and gates fan-out', () => {
  const termDf = [
    { term: 'chat', df: 100 }, { term: 'peer', df: 80 }, { term: 'rare', df: 1 },
    { term: 'video', df: 50 },
  ]
  const digest = dg.buildDigest(['d1', 'd2'], termDf, { topK: 3 })
  assert.deepEqual(digest.topTerms, ['chat', 'peer', 'video']) // top-3 by df
  assert.equal(dg.digestHasTerm(digest, 'chat'), true)
  assert.equal(dg.digestHasTerm(digest, 'rare'), false)
  assert.equal(dg.digestWorthPulling(digest, ['rare', 'video']), true)  // 'video' in head
  assert.equal(dg.digestWorthPulling(digest, ['rare', 'nope']), false)  // skip this contact
})

test('digest size is small (KB-scale) for thousands of docs', () => {
  const ids = Array.from({ length: 8000 }, (_, i) => 'id' + i)
  const terms = Array.from({ length: 5000 }, (_, i) => ({ term: 't' + i, df: i }))
  const digest = dg.buildDigest(ids, terms, { p: 0.001, topK: 2048 })
  const kb = dg.digestBytes(digest) / 1024
  assert.ok(kb < 60, `digest ~${kb.toFixed(1)} KB should be tens of KB, not the multi-MB full index`)
})