OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / client-prove-seeded.test.js

239 lines · javascript ref:
/**
 * client.proveSeeded (Tier-2 client side) — RPC roundtrip + verdict aggregation.
 *
 * The crypto is already covered by proof-of-storage.test.js; here the real
 * StorageProofService.prove runs in-process over a shared real temp-dir core,
 * callService is stubbed to route into it, and proveSeeded's own verifier
 * sandbox (a real temp Corestore) verifies each response end-to-end. We assert
 * the happy path is ok, and that a tampered/short/failed relay flips ok=false.
 */
import test from 'brittle'
import Hypercore from 'hypercore'
import b4a from 'b4a'
import sodium from 'sodium-universal'
import os from 'os'
import path from 'path'
import { readFile } from 'fs/promises'
import { HiveRelayClient } from 'p2p-hiverelay-client'
import { StorageProofService } from 'p2p-hiveservices/builtin/storage-proof-service.js'
import {
  PROOF_KIND_RETRIEVABILITY,
  RETRIEVABILITY_PROOF_LIMITATION,
  RETRIEVABILITY_PROOF_SIGNATURE_PROFILE
} from 'p2p-hiverelay/core/protocol/proof-of-storage.js'

let _n = 0
const tmp = () => path.join(os.tmpdir(), 'hr-ps-' + process.pid + '-' + (_n++))
function relayKeyPair () {
  const publicKey = b4a.alloc(sodium.crypto_sign_PUBLICKEYBYTES)
  const secretKey = b4a.alloc(sodium.crypto_sign_SECRETKEYBYTES)
  sodium.crypto_sign_keypair(publicKey, secretKey)
  return { publicKey, secretKey }
}

async function harness () {
  const core = new Hypercore(tmp())
  await core.ready()
  await core.append([b4a.from('a'), b4a.from('bb'), b4a.from('ccc'), b4a.from('dddd'), b4a.from('eeeee')])
  const keyHex = b4a.toString(core.key, 'hex')
  const relay = relayKeyPair()
  const relayHex = b4a.toString(relay.publicKey, 'hex')

  const svc = new StorageProofService()
  await svc.start({
    node: {
      keyPair: relay,
      appRegistry: {
        has: (k) => k === keyHex,
        get: (k) => (k === keyHex ? { drive: { core, db: { core }, closed: false, closing: false } } : null)
      }
    }
  })

  const client = Object.create(HiveRelayClient.prototype)
  client._started = true
  client._ensureStarted = () => {}
  client._serviceRequestId = 0
  client.relays = new Map([[relayHex, {}]])
  client._capabilityCache = new Map()
  client.open = async () => ({ core, ready: async () => {} })
  let routeSeen = null
  client.callService = async (service, method, params) => {
    routeSeen = service + '.' + method
    return svc.prove(params, { remotePubkey: 'cl'.repeat(32) })
  }
  client._routeSeen = () => routeSeen
  return { client, core, keyHex, relayHex, relay, svc }
}

test('proveSeeded: every sample verifies => ok true', async (t) => {
  const { client, keyHex, relayHex } = await harness()
  const r = await client.proveSeeded(keyHex, { relay: relayHex, samples: 4 })
  t.is(client._routeSeen(), 'storage-proof.prove', 'calls the storage-proof.prove route')
  t.ok(r.ok, 'overall ok')
  t.is(r.proofTransport, 'service')
  t.is(r.proofKind, PROOF_KIND_RETRIEVABILITY)
  t.is(r.proofLimit, RETRIEVABILITY_PROOF_LIMITATION)
  t.is(r.passed, r.total, 'all samples passed')
  t.is(r.total, 4)
  t.is(r.head, 5)
  t.ok(r.samples.every((sample) => sample.transport === 'service'), 'samples record service transport')
  t.ok(r.samples.every((sample) => sample.proofKind === PROOF_KIND_RETRIEVABILITY), 'samples carry proof kind')
})

test('proveSeeded: capability-advertised HTTP proof route is preferred over service RPC', async (t) => {
  const { client, keyHex, relayHex, svc } = await harness()
  client._capabilityCache.set('http://relay.test', {
    fetchedAt: Date.now(),
    relayInfo: {
      pubkey: relayHex,
      features: ['retrievability-proof-http']
    }
  })

  let httpCalls = 0
  let serviceCalls = 0
  client.callService = async () => {
    serviceCalls++
    throw new Error('service should not be called')
  }

  const fetchBefore = globalThis.fetch
  globalThis.fetch = async (url, opts = {}) => {
    httpCalls++
    t.is(url, 'http://relay.test/api/proof/retrievability')
    t.is(opts.method, 'POST')
    const params = JSON.parse(opts.body)
    const proof = await svc.prove(params, { caller: 'http-test' })
    return {
      ok: true,
      status: 200,
      text: async () => JSON.stringify({ ok: true, ...proof })
    }
  }
  t.teardown(() => { globalThis.fetch = fetchBefore })

  const r = await client.proveSeeded(keyHex, { relay: relayHex, samples: 2 })
  t.ok(r.ok, 'overall ok')
  t.is(r.proofTransport, 'http')
  t.is(httpCalls, 2, 'one HTTP proof call per sample')
  t.is(serviceCalls, 0, 'service route was not used')
  t.ok(r.samples.every((sample) => sample.transport === 'http'), 'samples record HTTP transport')
})

test('proveSeeded: opt-in proofSignatureProfile is sent through HTTP proofs', async (t) => {
  const { client, keyHex, relayHex, svc } = await harness()
  client._capabilityCache.set('http://relay.test', {
    fetchedAt: Date.now(),
    relayInfo: {
      pubkey: relayHex,
      features: ['retrievability-proof-http', 'retrievability-proof-domain-v1']
    }
  })

  const profiles = []
  const fetchBefore = globalThis.fetch
  globalThis.fetch = async (url, opts = {}) => {
    t.is(url, 'http://relay.test/api/proof/retrievability')
    const params = JSON.parse(opts.body)
    profiles.push(params.signatureProfile)
    const proof = await svc.prove(params, { caller: 'http-profile-test' })
    return {
      ok: true,
      status: 200,
      text: async () => JSON.stringify({ ok: true, ...proof })
    }
  }
  t.teardown(() => { globalThis.fetch = fetchBefore })

  const r = await client.proveSeeded(keyHex, {
    relay: relayHex,
    samples: 2,
    proofSignatureProfile: RETRIEVABILITY_PROOF_SIGNATURE_PROFILE
  })
  t.ok(r.ok, 'overall ok')
  t.is(r.proofTransport, 'http')
  t.is(profiles.length, 2, 'one profiled HTTP proof request per sample')
  t.ok(profiles.every((profile) => profile === RETRIEVABILITY_PROOF_SIGNATURE_PROFILE), 'HTTP requests carry the requested profile')
  t.ok(r.samples.every((sample) => sample.signatureProfile === RETRIEVABILITY_PROOF_SIGNATURE_PROFILE), 'sample verdicts expose the verified profile')
})

test('proveSeeded: tampered relay signature => ok false, all samples invalid', async (t) => {
  const { client, keyHex, relayHex } = await harness()
  const orig = client.callService
  client.callService = async (s, m, p) => {
    const resp = await orig(s, m, p)
    // Corrupt the last hex nibble of the signature.
    resp.signature = resp.signature.replace(/.$/, (ch) => (ch === '0' ? '1' : '0'))
    return resp
  }
  const r = await client.proveSeeded(keyHex, { relay: relayHex, samples: 3 })
  t.absent(r.ok, 'overall not ok')
  t.ok(r.samples.every((s) => !s.valid), 'every sample rejected')
})

test('proveSeeded: relay errors on every prove => ok false (CALL_FAILED)', async (t) => {
  const { client, keyHex, relayHex } = await harness()
  client.callService = async () => { throw new Error('SERVICE_TIMEOUT') }
  const r = await client.proveSeeded(keyHex, { relay: relayHex, samples: 2 })
  t.absent(r.ok)
  t.ok(r.samples.every((s) => /CALL_FAILED/.test(s.reason)), 'samples report CALL_FAILED')
})

test('proveSeeded: NO_RELAY when not connected to the named relay', async (t) => {
  const { client, keyHex } = await harness()
  await t.exception(client.proveSeeded(keyHex, { relay: 'de'.repeat(32) }), /NO_RELAY/)
})

test('proveSeeded: unsupported proofSignatureProfile is rejected locally', async (t) => {
  const { client, keyHex, relayHex } = await harness()
  await t.exception(
    client.proveSeeded(keyHex, { relay: relayHex, proofSignatureProfile: 'unknown-proof-profile' }),
    /proofSignatureProfile/
  )
})

test('proveSeeded: requires opts.relay', async (t) => {
  const { client, keyHex } = await harness()
  await t.exception(client.proveSeeded(keyHex, {}), /opts\.relay/)
})

test('proveSeeded: empty drive (head 0) => ok false, EMPTY_DRIVE', async (t) => {
  const { client, relayHex } = await harness()
  const empty = new Hypercore(tmp())
  await empty.ready()
  client.open = async () => ({ core: empty, ready: async () => {} })
  const r = await client.proveSeeded(b4a.toString(empty.key, 'hex'), { relay: relayHex, samples: 3 })
  t.absent(r.ok); t.is(r.reason, 'EMPTY_DRIVE'); t.is(r.total, 0)
  t.is(r.proofKind, PROOF_KIND_RETRIEVABILITY)
  t.is(r.proofTransport, 'service')
  await empty.close()
})

test('proveSeeded: sample indices are sodium-backed, distinct, and clamped to head', (t) => {
  const client = Object.create(HiveRelayClient.prototype)
  const originalRandom = Math.random
  Math.random = () => { throw new Error('Math.random should not be used for proof sampling') }
  t.teardown(() => { Math.random = originalRandom })

  const all = client._sampleIndices(5, 16)
  t.is(all.length, 5, 'sampling more than the head clamps to every block')
  t.alike([...all].sort((a, b) => a - b), [0, 1, 2, 3, 4], 'all indices are distinct and in range')

  const partial = client._sampleIndices(64, 8)
  t.is(partial.length, 8, 'requested sample count is preserved below the head')
  t.is(new Set(partial).size, partial.length, 'partial sample has no duplicates')
  t.ok(partial.every(index => Number.isInteger(index) && index >= 0 && index < 64), 'partial sample stays in range')

  t.alike(client._sampleIndices(0, 3), [], 'empty head samples nothing')
})

test('proveSeeded: client source does not import Node os builtin', async (t) => {
  const source = await readFile(new URL('../../packages/client/index.js', import.meta.url), 'utf8')
  t.absent(source.includes("import os from 'os'"), 'client source avoids Node os import')
  t.absent(source.includes('os.tmpdir()'), 'client source avoids os.tmpdir')
  t.ok(source.includes('function portableTmpdir'), 'client source has portable temp-dir helper')
  t.absent(source.includes('Math.random'), 'client source avoids predictable Math.random identifiers')
  t.ok(source.includes('function randomNameSuffix'), 'client source has sodium-backed name suffix helper')
})