OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / fork-quarantine-auth.test.js

258 lines · javascript ref:
/**
 * Regression tests for audit HR-SVC-004 — unauthenticated fork-proof
 * quarantine.
 *
 * The vulnerability: ForkDetector.report() never cryptographically
 * verified fork evidence, and Federation._pullForkProofs accepted
 * unsigned 'unverified-observer' proofs with no pubkey allow-list. Any
 * followed relay could quarantine ANY drive network-wide (censorship /
 * DoS).
 *
 * These tests prove:
 *   1. A forged / unsigned fork proof does NOT quarantine a drive on the
 *      network path.
 *   2. A genuine signed conflicting-heads proof (two different blocks
 *      each signed by the hypercore key) STILL quarantines.
 *   3. Federation._pullForkProofs gates pulled proofs on (a) a signed
 *      observer envelope, (b) a trusted-observer allow-list, and (c)
 *      cryptographic verification of the underlying fork evidence.
 *
 * They are deliberately brittle — they assert the exact security
 * behavior so a regression that re-opens the censorship hole fails loudly.
 */

import test from 'brittle'
import b4a from 'b4a'
import sodium from 'sodium-universal'
import { ForkDetector } from 'p2p-hiverelay/core/fork-detector.js'
import { Federation } from 'p2p-hiverelay/core/federation.js'
import {
  verifyForkEvidence,
  signForkProof
} from 'p2p-hiverelay/core/fork-proof-signing.js'

// ─── Test crypto helpers ──────────────────────────────────────────

function makeKeyPair () {
  const publicKey = b4a.alloc(32)
  const secretKey = b4a.alloc(64)
  sodium.crypto_sign_keypair(publicKey, secretKey)
  return { publicKey, secretKey }
}

// Build a GENUINE hypercore fork proof: two different block payloads,
// each carrying a valid Ed25519 signature by the hypercore's OWN key.
// This is what real equivocation looks like — the core key signed two
// conflicting heads at the same index.
function genuineForkProof () {
  const coreKeyPair = makeKeyPair() // the hypercore's keypair
  const hypercoreKey = b4a.toString(coreKeyPair.publicKey, 'hex')

  const blockA = b4a.from('head-A@index-7', 'utf8')
  const blockB = b4a.from('head-B@index-7-conflicting', 'utf8')

  const sigA = b4a.alloc(64)
  const sigB = b4a.alloc(64)
  sodium.crypto_sign_detached(sigA, blockA, coreKeyPair.secretKey)
  sodium.crypto_sign_detached(sigB, blockB, coreKeyPair.secretKey)

  return {
    hypercoreKey,
    coreKeyPair,
    blockIndex: 7,
    evidenceA: {
      fromRelay: 'r1',
      block: b4a.toString(blockA, 'hex'),
      signature: b4a.toString(sigA, 'hex')
    },
    evidenceB: {
      fromRelay: 'r2',
      block: b4a.toString(blockB, 'hex'),
      signature: b4a.toString(sigB, 'hex')
    }
  }
}

// A forged "proof": well-formed shape, but the signatures are junk that
// no hypercore key signed. This is what an attacker POSTs / gossips to
// try to quarantine an arbitrary drive.
function forgedForkProof (hypercoreKey = 'a'.repeat(64)) {
  return {
    hypercoreKey,
    blockIndex: 3,
    evidenceA: { fromRelay: 'attacker-1', block: 'ab'.repeat(16), signature: 'cd'.repeat(64) },
    evidenceB: { fromRelay: 'attacker-2', block: 'ef'.repeat(16), signature: 'ba'.repeat(64) }
  }
}

// ─── verifyForkEvidence primitive ─────────────────────────────────

test('verifyForkEvidence accepts genuine conflicting signed heads', async (t) => {
  const p = genuineForkProof()
  const r = verifyForkEvidence({ hypercoreKey: p.hypercoreKey, evidenceA: p.evidenceA, evidenceB: p.evidenceB })
  t.ok(r.valid, 'two blocks signed by the hypercore key = real fork')
})

test('verifyForkEvidence rejects forged (unsigned) evidence', async (t) => {
  const f = forgedForkProof()
  const r = verifyForkEvidence({ hypercoreKey: f.hypercoreKey, evidenceA: f.evidenceA, evidenceB: f.evidenceB })
  t.absent(r.valid, 'junk signatures do not verify under the hypercore key')
})

test('verifyForkEvidence rejects evidence signed by a DIFFERENT key', async (t) => {
  // Signatures are valid Ed25519 sigs, but by an attacker key — NOT the
  // hypercore key. This is the subtle attack: a well-formed signature
  // that isn't the core equivocating.
  const attacker = makeKeyPair()
  const victimHypercoreKey = b4a.toString(makeKeyPair().publicKey, 'hex')
  const blockA = b4a.from('x', 'utf8')
  const blockB = b4a.from('y', 'utf8')
  const sigA = b4a.alloc(64); sodium.crypto_sign_detached(sigA, blockA, attacker.secretKey)
  const sigB = b4a.alloc(64); sodium.crypto_sign_detached(sigB, blockB, attacker.secretKey)
  const r = verifyForkEvidence({
    hypercoreKey: victimHypercoreKey,
    evidenceA: { block: b4a.toString(blockA, 'hex'), signature: b4a.toString(sigA, 'hex') },
    evidenceB: { block: b4a.toString(blockB, 'hex'), signature: b4a.toString(sigB, 'hex') }
  })
  t.absent(r.valid, 'sigs by an attacker key are not the hypercore equivocating')
})

test('verifyForkEvidence rejects identical blocks (not a fork)', async (t) => {
  const kp = makeKeyPair()
  const hypercoreKey = b4a.toString(kp.publicKey, 'hex')
  const block = b4a.from('same', 'utf8')
  const sig = b4a.alloc(64); sodium.crypto_sign_detached(sig, block, kp.secretKey)
  const ev = { block: b4a.toString(block, 'hex'), signature: b4a.toString(sig, 'hex') }
  const r = verifyForkEvidence({ hypercoreKey, evidenceA: ev, evidenceB: { ...ev } })
  t.absent(r.valid, 'two copies of the same signed block is not equivocation')
})

// ─── ForkDetector.report() network provenance gate ────────────────

test('report(provenance:network) REFUSES forged evidence — no quarantine', async (t) => {
  const fd = new ForkDetector({})
  const f = forgedForkProof()
  let rejected = null
  fd.on('fork-report-rejected', (e) => { rejected = e })
  const r = fd.report({
    hypercoreKey: f.hypercoreKey,
    blockIndex: f.blockIndex,
    evidenceA: f.evidenceA,
    evidenceB: f.evidenceB,
    provenance: 'network'
  })
  t.absent(r.ok, 'forged network report is refused')
  t.ok(r.reason.includes('unverified fork evidence'), 'reason names the missing proof')
  t.ok(rejected, 'fork-report-rejected emitted')
  t.absent(fd.isQuarantined(f.hypercoreKey), 'drive is NOT quarantined by forged evidence')
})

test('report(provenance:network) ACCEPTS a genuine signed conflicting-heads proof', async (t) => {
  const fd = new ForkDetector({})
  const p = genuineForkProof()
  const r = fd.report({
    hypercoreKey: p.hypercoreKey,
    blockIndex: p.blockIndex,
    evidenceA: p.evidenceA,
    evidenceB: p.evidenceB,
    provenance: 'network'
  })
  t.ok(r.ok, 'genuine cryptographic fork proof is accepted')
  t.ok(fd.isQuarantined(p.hypercoreKey), 'genuine fork quarantines the drive')
})

test('report() local provenance still accepts lightweight self-detected evidence', async (t) => {
  // Preserve legitimate LOCAL fork handling: the client observed its own
  // core truncate / its own quorum diverge. That path uses non-crypto
  // placeholder evidence and must keep working.
  const fd = new ForkDetector({})
  const key = 'a'.repeat(64)
  const r = fd.report({
    hypercoreKey: key,
    blockIndex: 0,
    evidenceA: { fromRelay: 'local', block: 'truncate-pre', signature: 'auto-pre' },
    evidenceB: { fromRelay: 'replication', block: 'truncate-post', signature: 'auto-post' }
    // provenance defaults to 'local'
  })
  t.ok(r.ok, 'local self-detection is internally trusted')
  t.ok(fd.isQuarantined(key), 'local fork still quarantines')
})

// ─── Federation._pullForkProofs authentication gates ──────────────

// Minimal node stub carrying a real ForkDetector, so we can drive
// _pullForkProofs without spinning a RelayNode. We override _fetchJson
// to return a canned /api/forks/proofs payload.
function fedWith (proofs, { trustedForkObservers = [] } = {}) {
  const forkDetector = new ForkDetector({})
  const fed = new Federation({ node: { forkDetector }, trustedForkObservers })
  fed._fetchJson = async () => ({ proofs })
  return { fed, forkDetector }
}

test('_pullForkProofs REJECTS bare unsigned proofs (no unverified-observer path)', async (t) => {
  const f = forgedForkProof()
  const bare = { hypercoreKey: f.hypercoreKey, blockIndex: f.blockIndex, evidence: [f.evidenceA, f.evidenceB] }
  const { fed, forkDetector } = fedWith([bare], { trustedForkObservers: [] })
  await fed._pullForkProofs('http://peer.test')
  t.absent(forkDetector.isQuarantined(f.hypercoreKey), 'unsigned proof cannot quarantine')
})

test('_pullForkProofs REJECTS signed proof from an observer NOT on the allow-list', async (t) => {
  const p = genuineForkProof()
  const observer = makeKeyPair()
  const signed = signForkProof(
    { hypercoreKey: p.hypercoreKey, blockIndex: p.blockIndex, evidence: [p.evidenceA, p.evidenceB] },
    observer
  )
  // Allow-list is EMPTY → even a genuine, validly-signed proof is refused.
  const { fed, forkDetector } = fedWith([signed], { trustedForkObservers: [] })
  await fed._pullForkProofs('http://peer.test')
  t.absent(forkDetector.isQuarantined(p.hypercoreKey), 'untrusted observer cannot quarantine (fail closed)')
})

test('_pullForkProofs REJECTS trusted observer whose evidence is forged', async (t) => {
  // Observer is trusted, envelope signature is valid — but the underlying
  // fork evidence is junk. Must NOT quarantine: a trusted observer still
  // cannot censor a drive it has no cryptographic proof against.
  const f = forgedForkProof()
  const observer = makeKeyPair()
  const observerHex = b4a.toString(observer.publicKey, 'hex')
  const signed = signForkProof(
    { hypercoreKey: f.hypercoreKey, blockIndex: f.blockIndex, evidence: [f.evidenceA, f.evidenceB] },
    observer
  )
  const { fed, forkDetector } = fedWith([signed], { trustedForkObservers: [observerHex] })
  let rejected = null
  fed.on('fork-proof-rejected', (e) => { rejected = e })
  await fed._pullForkProofs('http://peer.test')
  t.absent(forkDetector.isQuarantined(f.hypercoreKey), 'trusted observer + forged evidence = no quarantine')
  t.ok(rejected, 'rejection surfaced')
})

test('_pullForkProofs QUARANTINES on a trusted observer + genuine signed conflicting-heads proof', async (t) => {
  const p = genuineForkProof()
  const observer = makeKeyPair()
  const observerHex = b4a.toString(observer.publicKey, 'hex')
  const signed = signForkProof(
    { hypercoreKey: p.hypercoreKey, blockIndex: p.blockIndex, evidence: [p.evidenceA, p.evidenceB] },
    observer
  )
  const { fed, forkDetector } = fedWith([signed], { trustedForkObservers: [observerHex] })
  let merged = null
  fed.on('fork-proofs-merged', (e) => { merged = e })
  await fed._pullForkProofs('http://peer.test')
  t.ok(forkDetector.isQuarantined(p.hypercoreKey), 'legitimate proven fork DOES quarantine')
  t.ok(merged && merged.count === 1, 'merged exactly one proof')
})

test('trustedForkObservers config drops malformed entries (no silent trust widening)', async (t) => {
  const fed = new Federation({
    node: { forkDetector: new ForkDetector({}) },
    trustedForkObservers: ['not-hex', 'AB'.repeat(32), 42, null, 'zz'.repeat(32)]
  })
  // Only the valid 64-hex entry survives (lowercased).
  t.is(fed.trustedForkObservers.size, 1)
  t.ok(fed.trustedForkObservers.has('ab'.repeat(32)))
})