OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / gateway-verify-bundle.test.js

373 lines · javascript ref:
/**
 * R1 verifiable retrieval mode — gateway proof bundles + client verifier.
 *
 * Round-trips through a REAL Hyperdrive on an in-process Corestore (golden
 * drive, known content): the gateway's `?verify=1` / hc-block Accept surfaces
 * return a versioned bundle instead of raw bytes, and the independent client
 * verifier (packages/client/verify-block.js) must ACCEPT genuine bundles and
 * REJECT every tamper — wrong bytes, wrong index, stale header, wrong key,
 * path swap, forged signature — without trusting the gateway.
 *
 * Also pins the mode's edges: single-range-only behavior + frozen byte caps
 * identical to the raw lane, and identical admission (blind stays hard-403).
 */
import test from 'brittle'
import http from 'http'
import os from 'os'
import path from 'path'
import Corestore from 'corestore'
import Hyperdrive from 'hyperdrive'
import c from 'compact-encoding'
import { wire } from 'hypercore/lib/messages.js'
import { HyperGateway } from 'p2p-hiverelay/gateway'
import { verifyBlockBundle } from 'p2p-hiverelay-client/verify-block.js'
import { issueExactAppContext } from '../../packages/core/gateway/exact-app-context.js'

const HELLO = Buffer.from('hello world hello world hello world') // 35 bytes, 1 block
const BIG = Buffer.alloc(200000) // 4 blocks: 3 × 65536 + 3392
for (let i = 0; i < BIG.length; i++) BIG[i] = i % 251

let _n = 0
function tmp () { return path.join(os.tmpdir(), 'hr-gw-verify-' + process.pid + '-' + (_n++)) }

async function goldenDrive () {
  const store = new Corestore(tmp())
  const drive = new Hyperdrive(store)
  await drive.ready()
  await drive.put('/hello.txt', HELLO)
  await drive.put('/big.bin', BIG)
  await drive.put('/index.html', Buffer.from('<h1>golden</h1>'))
  await drive.put('/empty.txt', Buffer.alloc(0))
  return { store, drive }
}

async function bootGateway (t, drive, store, { entryExtras = {}, gatewayOpts = {}, context = null } = {}) {
  const keyHex = drive.key.toString('hex')
  const node = {
    config: { gatewayPublicOnlyPrivacyTier: true, hiveAppPublicKeys: [keyHex] },
    seededApps: new Map([[keyHex, { drive, blind: false, privacyTier: 'public', ...entryExtras }]])
  }
  const gateway = new HyperGateway(node, gatewayOpts)
  const server = http.createServer((req, res) => {
    if (!context) return gateway.handle(req, res)
    const url = new URL(req.url, 'http://localhost')
    gateway.handle(req, res, issueExactAppContext({
      appKey: keyHex,
      path: url.pathname,
      byteMode: 'exact',
      publicAppKeys: [keyHex]
    }))
  })
  await new Promise((resolve, reject) => {
    server.once('error', reject)
    server.listen(0, '127.0.0.1', resolve)
  })
  t.teardown(async () => {
    if (typeof server.closeAllConnections === 'function') {
      try { server.closeAllConnections() } catch (_) {}
    }
    await new Promise(resolve => server.close(resolve))
    await gateway.close()
    await drive.close()
    await store.close()
  })
  return {
    gateway,
    keyHex,
    port: server.address().port,
    path: (filePath = '/hello.txt', query = '') => `/v1/hyper/${keyHex}${filePath}${query}`
  }
}

function request (port, method, urlPath, headers = {}) {
  return new Promise((resolve, reject) => {
    const req = http.request({
      hostname: '127.0.0.1',
      port,
      method,
      path: urlPath,
      agent: false,
      headers: { Connection: 'close', ...headers }
    }, (res) => {
      const chunks = []
      res.on('data', chunk => chunks.push(chunk))
      res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }))
      res.on('error', reject)
    })
    req.on('error', reject)
    req.end()
  })
}

const getJson = async (port, urlPath, headers = {}) => {
  const res = await request(port, 'GET', urlPath, headers)
  let body = null
  try { body = JSON.parse(res.body.toString()) } catch {}
  return { ...res, json: body }
}

function flipHexByte (hex, at = 0) {
  const i = at * 2
  const flipped = hex.slice(i, i + 2) === '00' ? 'ff' : '00'
  return hex.slice(0, i) + flipped + hex.slice(i + 2)
}

test('verify=1 round-trip: bundle proves golden file bytes against the drive key', async (t) => {
  const { store, drive } = await goldenDrive()
  const { port, keyHex, path: p } = await bootGateway(t, drive, store)

  const res = await getJson(port, p('/hello.txt', '?verify=1'))
  t.is(res.status, 200)
  t.is(res.headers['content-type'], 'application/vnd.hiverelay.hc-block+json')
  t.is(res.headers['x-hive-drive-version'], String(drive.version))

  const b = res.json
  t.is(b.v, 1)
  t.is(b.driveKey, keyHex)
  t.is(b.driveVersion, drive.version)
  t.is(b.path, '/hello.txt')
  t.is(b.blockIndex, 0)
  t.alike(b.blob, { blockOffset: 0, blockLength: 1, byteOffset: 0, byteLength: HELLO.length, blockSize: 65536 })
  t.alike(b.fileRange, { start: 0, end: HELLO.length - 1 })
  t.ok(/^[0-9a-f]{64}$/.test(b.blobsKey))
  t.ok(b.treeHeader.length >= 1 && /^[0-9a-f]{64}$/.test(b.treeHeader.rootHash) && b.treeHeader.signature.length > 0)
  t.ok(b.entry.treeHeader.length === drive.version)

  const verdict = await verifyBlockBundle(b)
  t.ok(verdict.valid, 'reason: ' + verdict.reason)
  t.ok(verdict.blockBytes.equals(HELLO))
  t.is(verdict.driveVersion, drive.version)
})

test('Accept: application/vnd.hiverelay.hc-block triggers the same bundle (+json form too)', async (t) => {
  const { store, drive } = await goldenDrive()
  const { port, path: p } = await bootGateway(t, drive, store)

  for (const accept of ['application/vnd.hiverelay.hc-block', 'application/vnd.hiverelay.hc-block+json']) {
    const res = await getJson(port, p('/hello.txt'), { Accept: accept })
    t.is(res.status, 200, accept)
    t.is(res.headers['content-type'], 'application/vnd.hiverelay.hc-block+json')
    const verdict = await verifyBlockBundle(res.json)
    t.ok(verdict.valid, accept + ' reason: ' + verdict.reason)
  }

  // No trigger → the raw lane is untouched.
  const raw = await request(port, 'GET', p('/hello.txt'))
  t.is(raw.status, 200)
  t.ok(raw.body.equals(HELLO))
  t.is(raw.headers['content-type'], 'text/plain; charset=utf-8')
})

test('multi-block file: ranges map to single blob blocks, all blocks verify', async (t) => {
  const { store, drive } = await goldenDrive()
  const { port, path: p } = await bootGateway(t, drive, store)

  // No range → first block of the file (clients iterate via the blob descriptor).
  const first = await getJson(port, p('/big.bin', '?verify=1'))
  t.is(first.status, 200)
  t.is(first.json.blockIndex, first.json.blob.blockOffset)
  t.alike(first.json.fileRange, { start: 0, end: 65535 })
  t.is(first.json.blob.blockLength, 4)
  t.ok((await verifyBlockBundle(first.json)).valid)
  t.ok(Buffer.from(first.json.blockBytes, 'hex').equals(BIG.subarray(0, 65536)))

  // Interior range → the block containing it.
  const mid = await getJson(port, p('/big.bin', '?verify=1'), { Range: 'bytes=70000-70010' })
  t.is(mid.status, 200)
  t.is(mid.json.blockIndex, mid.json.blob.blockOffset + 1)
  t.alike(mid.json.fileRange, { start: 65536, end: 131071 })
  const midVerdict = await verifyBlockBundle(mid.json)
  t.ok(midVerdict.valid, 'reason: ' + midVerdict.reason)
  t.ok(midVerdict.blockBytes.subarray(70000 - 65536, 70011 - 65536).equals(BIG.subarray(70000, 70011)))

  // Last (short) block.
  const tail = await getJson(port, p('/big.bin', '?verify=1'), { Range: 'bytes=199000-199999' })
  t.is(tail.status, 200)
  t.is(tail.json.blockIndex, tail.json.blob.blockOffset + 3)
  t.alike(tail.json.fileRange, { start: 196608, end: 199999 })
  const tailVerdict = await verifyBlockBundle(tail.json)
  t.ok(tailVerdict.valid, 'reason: ' + tailVerdict.reason)
  t.ok(tailVerdict.blockBytes.equals(BIG.subarray(196608)))
})

test('single-range only: spanning range 400, invalid range 416, multi-range 416', async (t) => {
  const { store, drive } = await goldenDrive()
  const { port, path: p } = await bootGateway(t, drive, store)

  const spanning = await getJson(port, p('/big.bin', '?verify=1'), { Range: 'bytes=65500-65600' })
  t.is(spanning.status, 400)
  t.ok(spanning.json.error.includes('one blob block'))

  const invalid = await getJson(port, p('/big.bin', '?verify=1'), { Range: 'bytes=999999999-' })
  t.is(invalid.status, 416)
  t.is(invalid.headers['content-range'], 'bytes */200000')

  const multi = await getJson(port, p('/big.bin', '?verify=1'), { Range: 'bytes=0-1,4-5' })
  t.is(multi.status, 416)
})

test('frozen limits: range beyond cap 416s, no-range oversized file 413s — like the raw lane', async (t) => {
  const { store, drive } = await goldenDrive()
  const { port, path: p } = await bootGateway(t, drive, store, { gatewayOpts: { maxResponseBytes: 100 } })

  const noRange = await getJson(port, p('/big.bin', '?verify=1'))
  t.is(noRange.status, 413)
  t.is(noRange.json.maxResponseBytes, 100)

  // A range whose length exceeds the cap refuses like the raw lane (a range
  // that clamps inside the file — bytes=0-199 on a 35-byte file — does not).
  const overCap = await getJson(port, p('/big.bin', '?verify=1'), { Range: 'bytes=0-199' })
  t.is(overCap.status, 416)
  t.is(overCap.json.maxResponseBytes, 100)

  // Within cap still serves (blockBytes are the proof unit and stay whole).
  const ok = await getJson(port, p('/hello.txt', '?verify=1'), { Range: 'bytes=0-10' })
  t.is(ok.status, 200)
  t.ok((await verifyBlockBundle(ok.json)).valid)
})

test('verify mode edges: directory 400, empty file 400, missing 404, HEAD headers-only', async (t) => {
  const { store, drive } = await goldenDrive()
  const { port, path: p } = await bootGateway(t, drive, store)

  const dir = await getJson(port, p('/missing-dir/', '?verify=1'))
  t.is(dir.status, 400)
  t.ok(dir.json.error.includes('file blocks only'))

  // A directory WITH an index.html resolves to the file and proves normally.
  const index = await getJson(port, p('/', '?verify=1'))
  t.is(index.status, 200)
  t.is(index.json.path, '/index.html')
  t.ok((await verifyBlockBundle(index.json)).valid)

  const empty = await getJson(port, p('/empty.txt', '?verify=1'))
  t.is(empty.status, 400)
  t.ok(empty.json.error.includes('blob blocks'))

  const missing = await getJson(port, p('/nope.txt', '?verify=1'))
  t.is(missing.status, 404)

  const head = await request(port, 'HEAD', p('/hello.txt', '?verify=1'))
  t.is(head.status, 200)
  t.is(head.headers['content-type'], 'application/vnd.hiverelay.hc-block+json')
  t.is(head.body.length, 0)
  t.ok(Number(head.headers['content-length']) > 0)
})

test('same admission as the raw lane: blind app stays hard-403 in verify mode', async (t) => {
  const { store, drive } = await goldenDrive()
  const { port, path: p } = await bootGateway(t, drive, store, { entryExtras: { blind: true } })

  const res = await getJson(port, p('/hello.txt', '?verify=1'))
  t.is(res.status, 403)
  t.ok(res.json.blind)
})

test('app-origin lane: exact context serves a verifying bundle with exact-lane headers', async (t) => {
  const { store, drive } = await goldenDrive()
  const { port } = await bootGateway(t, drive, store, {
    entryExtras: { storageClass: 'persistent', availabilityClass: 'always-on' },
    context: true
  })

  const res = await getJson(port, '/hello.txt?verify=1')
  t.is(res.status, 200)
  t.is(res.headers['content-type'], 'application/vnd.hiverelay.hc-block+json')
  t.is(res.headers['x-hive-byte-mode'], 'verified')
  t.is(res.headers['x-hive-app-key'], drive.key.toString('hex'))
  t.is(res.headers.vary, 'Host')
  t.is(res.headers['cache-control'], 'no-store, max-age=0')
  t.is(res.headers['x-hive-drive-version'], String(drive.version))

  const verdict = await verifyBlockBundle(res.json)
  t.ok(verdict.valid, 'reason: ' + verdict.reason)
  t.ok(verdict.blockBytes.equals(HELLO))

  // Accept trigger works on the app-origin lane too.
  const negotiated = await getJson(port, '/hello.txt', { Accept: 'application/vnd.hiverelay.hc-block' })
  t.is(negotiated.status, 200)
  t.ok((await verifyBlockBundle(negotiated.json)).valid)
})

test('tamper: wrong bytes, wrong index, stale header, wrong key, path swap, forgeries all REJECT', async (t) => {
  const { store, drive } = await goldenDrive()
  const { port, path: p } = await bootGateway(t, drive, store)

  const bundle = (await getJson(port, p('/big.bin', '?verify=1'), { Range: 'bytes=0-10' })).json
  t.ok((await verifyBlockBundle(bundle)).valid, 'baseline verifies')

  // Wrong bytes (envelope no longer matches the proved block).
  const wrongBytes = { ...bundle, blockBytes: flipHexByte(bundle.blockBytes, 10) }
  let v = await verifyBlockBundle(wrongBytes)
  t.absent(v.valid); t.is(v.reason, 'BLOCK_BYTES_MISMATCH')

  // Wrong index, still inside the blob extent.
  const wrongIndex = { ...bundle, blockIndex: bundle.blockIndex + 1 }
  v = await verifyBlockBundle(wrongIndex)
  t.absent(v.valid); t.is(v.reason, 'BLOCK_INDEX_MISMATCH')

  // Wrong index beyond the entry-proved blob extent.
  const outOfRange = { ...bundle, blockIndex: bundle.blob.blockOffset + bundle.blob.blockLength + 4 }
  v = await verifyBlockBundle(outOfRange)
  t.absent(v.valid); t.is(v.reason, 'BLOCK_OUT_OF_RANGE')

  // Wrong drive key — the manifest hash no longer matches.
  const other = await goldenDrive()
  t.teardown(async () => { await other.drive.close(); await other.store.close() })
  const wrongKey = { ...bundle, driveKey: other.drive.key.toString('hex') }
  v = await verifyBlockBundle(wrongKey)
  t.absent(v.valid); t.is(v.reason, 'DRIVE_KEY_MISMATCH')

  // Path swap — the proved bee node names the real path.
  const wrongPath = { ...bundle, path: '/hello.txt' }
  v = await verifyBlockBundle(wrongPath)
  t.absent(v.valid); t.is(v.reason, 'PATH_MISMATCH')

  // Blob descriptor claim that disagrees with the proved entry.
  const wrongBlob = { ...bundle, blob: { ...bundle.blob, byteLength: bundle.blob.byteLength - 1 } }
  v = await verifyBlockBundle(wrongBlob)
  t.absent(v.valid); t.is(v.reason, 'BLOB_MISMATCH')

  // Forged tree header signature (no longer the signature the proof carried).
  const forgedHeader = { ...bundle, treeHeader: { ...bundle.treeHeader, signature: flipHexByte(bundle.treeHeader.signature, 4) } }
  v = await verifyBlockBundle(forgedHeader)
  t.absent(v.valid); t.is(v.reason, 'BLOCK_HEADER_MISMATCH')

  // Forged proof (flipped byte inside the signed upgrade) — rejected: the
  // header no longer matches the tampered proof, or hypercore itself rejects.
  const decoded = c.decode(wire.data, Buffer.from(bundle.proof, 'hex'))
  decoded.upgrade.signature[0] ^= 0xff
  const forgedProof = { ...bundle, proof: c.encode(wire.data, decoded).toString('hex') }
  v = await verifyBlockBundle(forgedProof)
  t.absent(v.valid)
  t.ok(v.reason === 'BLOCK_HEADER_MISMATCH' || (v.reason && v.reason.startsWith('BLOCK_INVALID')), 'reason: ' + v.reason)

  // Stale header: bundle the drive BEFORE a write, then mix its tree headers
  // into the post-write bundle — old headers must not ride a new proof.
  const before = { treeHeader: bundle.treeHeader, entryHeader: bundle.entry.treeHeader, driveVersion: bundle.driveVersion }
  await drive.put('/later.txt', Buffer.from('written after the first bundle'))
  const after = (await getJson(port, p('/big.bin', '?verify=1'), { Range: 'bytes=0-10' })).json
  t.ok(after.driveVersion > before.driveVersion)

  const staleEntryHeader = { ...after, entry: { ...after.entry, treeHeader: before.entryHeader } }
  v = await verifyBlockBundle(staleEntryHeader)
  t.absent(v.valid); t.is(v.reason, 'ENTRY_HEADER_MISMATCH')

  const staleBlobHeader = { ...after, treeHeader: before.treeHeader }
  v = await verifyBlockBundle(staleBlobHeader)
  t.absent(v.valid); t.is(v.reason, 'BLOCK_HEADER_MISMATCH')

  // …but a self-consistent OLD bundle still verifies at ITS pinned version:
  // staleness is a version-pinning decision for the client, not a forgery.
  t.ok((await verifyBlockBundle(bundle)).valid, 'old bundle remains valid at its driveVersion')
})

test('envelope version gate: unknown v rejects before any crypto', async (t) => {
  const { store, drive } = await goldenDrive()
  const { port, path: p } = await bootGateway(t, drive, store)
  const bundle = (await getJson(port, p('/hello.txt', '?verify=1'))).json
  const v = await verifyBlockBundle({ ...bundle, v: 2 })
  t.absent(v.valid)
  t.is(v.reason, 'ENVELOPE_VERSION_UNSUPPORTED')
})