OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / gateway / verify-bundle.js

306 lines · javascript ref:
/**
 * Verifiable retrieval mode (R1) — trustless proof bundles for gateway reads.
 *
 * A normal gateway GET returns raw file bytes and the client must TRUST the
 * gateway (TLS proves transport, not Hypercore provenance). When the request
 * carries `?verify=1` or `Accept: application/vnd.hiverelay.hc-block`, the
 * gateway instead returns a versioned VERIFICATION BUNDLE: the blob block's
 * bytes + a Hypercore Merkle block proof + the drive's signed tree headers,
 * so a client can verify the bytes hash into the drive key's signed root
 * WITHOUT trusting the gateway.
 *
 * Envelope — Content-Type `application/vnd.hiverelay.hc-block+json`, `v: 1`:
 *
 *   {
 *     v: 1,                      // envelope format version
 *     driveKey,                  // 64-hex drive (metadata/bee core) key — the
 *                                //   client's root of trust; on hc11 it is the
 *                                //   manifest hash, checked against the entry proof
 *     driveVersion,              // bee core length the bundle pins to (the same
 *                                //   version X-Hive-Drive-Version advertises)
 *     path,                      // resolved drive path the bundle serves
 *     blockIndex,                // absolute index of blockBytes in the BLOBS core
 *     blockBytes,                // hex raw blob block (one block of file bytes)
 *     fileRange: { start, end }, // file-byte offsets blockBytes covers (inclusive)
 *     blob: { blockOffset, blockLength, byteOffset, byteLength, blockSize },
 *                                // the path's blob descriptor (proved via entry);
 *                                //   blockSize is the blob store's chunk size,
 *                                //   checked arithmetically vs blockLength/byteLength
 *     blobsKey,                  // 64-hex blobs (content) core key — derived
 *                                //   independently from the drive manifest
 *     proof,                     // hex wire.data block proof of blockBytes in the
 *                                //   blobs core (embeds the blobs-core manifest)
 *     treeHeader: { fork, length, rootHash, signature },
 *                                // blobs core signed tree header at the proven length
 *     entry: {
 *       blockIndex,              // bee-core index of the path's node block
 *       blockBytes,              // hex raw protobuf bee node (binds path → blob)
 *       proof,                   // hex wire.data block proof in the bee core,
 *                                //   upgrade pinned at driveVersion (drive manifest)
 *       treeHeader               // bee core signed tree header AT driveVersion
 *     }
 *   }
 *
 * Trust chain (all re-derived client-side — see packages/client/verify-block.js):
 * driveKey ⇒ manifest hash ⇒ drive manifest ⇒ getContentManifest ⇒ blobs key;
 * the entry proof binds path→blob into the drive key's signed root at
 * driveVersion; the block proof binds blockBytes into the blobs core's signed
 * root. Forged/substituted bytes, a wrong block, a wrong path binding, or a
 * stale header all fail verification.
 *
 * Scope: ONE blob block per request (single range only; the frozen byte caps
 * refuse exactly like the raw lane); clients iterate blocks using the proved
 * blob descriptor. Public drives only — the admission chain runs BEFORE this
 * module, so blind/custody keys stay hard-403 exactly as on the raw lane.
 * Proofs read local storage only (core.proof never fetches): a gateway that
 * cannot prove honestly fails the request instead of fetching to fabricate.
 */

import c from 'compact-encoding'
import b4a from 'b4a'
import { wire } from 'hypercore/lib/messages.js'
import { parseRange } from './hyper-gateway.js'

export const HC_BLOCK_ENVELOPE_VERSION = 1
export const HC_BLOCK_MEDIA_TYPE = 'application/vnd.hiverelay.hc-block'
export const HC_BLOCK_MEDIA_TYPE_JSON = 'application/vnd.hiverelay.hc-block+json'

/**
 * Verify-mode trigger surfaces: an explicit `?verify=1` query flag, or content
 * negotiation for the bundle media type (with or without the `+json` suffix).
 */
export function isVerifyRequest (url, headers) {
  if (url && url.searchParams && url.searchParams.get('verify') === '1') return true
  const accept = headers && headers.accept
  return typeof accept === 'string' && accept.includes(HC_BLOCK_MEDIA_TYPE)
}

/**
 * Build + send the verification bundle for an already-resolved file entry.
 * Called from HyperGateway._handleRequest AFTER the normal admission chain
 * (seeded/blind/tier/PolicyGuard) and drive pinning, with the same `readDrive`
 * the raw lane would stream from. All failure modes answer with the same JSON
 * error shape as the raw lane.
 *
 * @param {HyperGateway} gateway - owning gateway (timeouts, byte caps, events)
 * @param {object} res - HTTP response
 * @param {Hyperdrive} drive - the (possibly checked-out) drive being served
 * @param {string} keyHex - 64-hex drive key
 * @param {string} filePath - resolved file path (after index.html rewrite)
 * @param {object} entry - the bee entry for filePath (entry.value.blob present)
 * @param {number} byteLength - entry.value.blob.byteLength (already validated)
 * @param {object} opts - { head, exactBytes, rangeHeader, driveVersion, signal,
 *   sendJson, reserveResponseBytes, egressRetryAfterSeconds }
 */
export async function serveVerifyBundle (gateway, res, drive, keyHex, filePath, entry, byteLength, opts = {}) {
  const sendJson = opts.sendJson
  const blob = entry && entry.value && entry.value.blob
  const blobFields = blob && [blob.blockOffset, blob.blockLength, blob.byteOffset, blob.byteLength]
  if (!blobFields || !blobFields.every(Number.isSafeInteger) ||
      blob.blockOffset < 0 || blob.blockLength < 1 || blob.byteOffset < 0 || blob.byteLength < 1 ||
      !Number.isSafeInteger(entry.seq) || entry.seq < 0) {
    // Empty files have no blob block to prove; anything else malformed is not provable.
    sendJson({ error: 'Verify mode requires a file with blob blocks', path: filePath }, 400)
    return
  }

  const blobs = await gateway._withTimeout(
    drive.getBlobs(),
    gateway._driveOperationTimeout,
    'drive.getBlobs()',
    opts.signal
  )
  const blobsCore = blobs && blobs.core
  const blockSize = blobs && blobs.blockSize
  if (!blobsCore || typeof blobsCore.proof !== 'function' || !Number.isSafeInteger(blockSize) || blockSize < 1) {
    sendJson({ error: 'Gateway verify bundle failed' }, 502)
    return
  }
  const beeCore = drive.core
  if (!beeCore || typeof beeCore.proof !== 'function') {
    sendJson({ error: 'Gateway verify bundle failed' }, 502)
    return
  }

  // ─── Range → single blob block ─────────────────────────────────
  // Same parser, statuses, and frozen byte caps as the raw lane; verify mode
  // is simply stricter about one thing the raw lane never needed: the span
  // must fit inside ONE blob block, because the block is the proof unit.
  let blockRel = 0
  const rangeHeader = opts.rangeHeader
  if (rangeHeader) {
    const parsed = parseRange(rangeHeader, byteLength)
    if (parsed === 'invalid' || parsed === 'unsupported') {
      res.setHeader('Content-Range', `bytes */${byteLength}`)
      sendJson({ error: 'Range Not Satisfiable' }, 416)
      return
    }
    const { start, end } = parsed
    const responseLength = end - start + 1
    if (responseLength > gateway._maxResponseBytes) {
      res.setHeader('Content-Range', `bytes */${byteLength}`)
      sendJson({
        error: 'Requested range exceeds gateway byte limit',
        maxResponseBytes: gateway._maxResponseBytes
      }, 416)
      return
    }
    const firstBlock = Math.floor(start / blockSize)
    if (Math.floor(end / blockSize) !== firstBlock) {
      sendJson({
        error: 'Verify mode proves one blob block per request — narrow the range to a single block',
        blockSize
      }, 400)
      return
    }
    blockRel = firstBlock
  } else if (byteLength > gateway._maxResponseBytes) {
    sendJson({
      error: 'A bounded single byte range is required',
      maxResponseBytes: gateway._maxResponseBytes
    }, 413)
    return
  }

  const blockIndex = blob.blockOffset + blockRel
  if (blockIndex >= blobsCore.length) {
    sendJson({ error: 'Gateway verify bundle failed' }, 502)
    return
  }

  // ─── Proofs (local storage only — proof() never touches the swarm) ───
  const driveVersion = opts.driveVersion
  const blobProof = await gateway._withTimeout(
    blobsCore.proof({ block: { index: blockIndex }, upgrade: { start: 0, length: blobsCore.length } }),
    gateway._driveOperationTimeout,
    'blobs.proof()',
    opts.signal
  )
  if (!blobProof || !blobProof.block || !b4a.isBuffer(blobProof.block.value) ||
      !blobProof.upgrade || !b4a.isBuffer(blobProof.upgrade.signature)) {
    // We claim to serve these bytes, so we must hold them — no fetch-on-demand.
    sendJson({ error: 'Gateway verify bundle failed' }, 502)
    return
  }
  const entryProof = await gateway._withTimeout(
    beeCore.proof({ block: { index: entry.seq }, upgrade: { start: 0, length: driveVersion } }),
    gateway._driveOperationTimeout,
    'drive.proof()',
    opts.signal
  )
  if (!entryProof || !entryProof.block || !b4a.isBuffer(entryProof.block.value) ||
      !entryProof.upgrade || !b4a.isBuffer(entryProof.upgrade.signature)) {
    sendJson({ error: 'Gateway verify bundle failed' }, 502)
    return
  }

  const blobTreeHash = await gateway._withTimeout(
    blobsCore.treeHash(blobProof.upgrade.length),
    gateway._driveOperationTimeout,
    'blobs.treeHash()',
    opts.signal
  )
  const entryTreeHash = await gateway._withTimeout(
    beeCore.treeHash(driveVersion),
    gateway._driveOperationTimeout,
    'drive.treeHash()',
    opts.signal
  )

  // Same wire shaping as proof-of-storage.js: manifest cores need their
  // manifest shipped so a key-only verifier can check the signed upgrade.
  const blobManifest = blobsCore.core.compat ? null : blobsCore.core.header.manifest
  const entryManifest = beeCore.core.compat ? null : beeCore.core.header.manifest
  const blockBytes = blobProof.block.value
  const fileStart = blockRel * blockSize

  const envelope = {
    v: HC_BLOCK_ENVELOPE_VERSION,
    driveKey: keyHex,
    driveVersion,
    path: filePath,
    blockIndex,
    blockBytes: b4a.toString(blockBytes, 'hex'),
    fileRange: { start: fileStart, end: fileStart + blockBytes.byteLength - 1 },
    blob: {
      blockOffset: blob.blockOffset,
      blockLength: blob.blockLength,
      byteOffset: blob.byteOffset,
      byteLength: blob.byteLength,
      blockSize
    },
    blobsKey: b4a.toString(blobsCore.key, 'hex'),
    proof: b4a.toString(c.encode(wire.data, {
      request: 0,
      fork: blobProof.fork,
      block: blobProof.block,
      hash: null,
      seek: null,
      upgrade: blobProof.upgrade || null,
      manifest: blobManifest
    }), 'hex'),
    treeHeader: {
      fork: blobProof.fork,
      length: blobProof.upgrade.length,
      rootHash: b4a.toString(blobTreeHash, 'hex'),
      signature: b4a.toString(blobProof.upgrade.signature, 'hex')
    },
    entry: {
      blockIndex: entry.seq,
      blockBytes: b4a.toString(entryProof.block.value, 'hex'),
      proof: b4a.toString(c.encode(wire.data, {
        request: 0,
        fork: entryProof.fork,
        block: entryProof.block,
        hash: null,
        seek: null,
        upgrade: entryProof.upgrade || null,
        manifest: entryManifest
      }), 'hex'),
      treeHeader: {
        fork: entryProof.fork,
        length: driveVersion,
        rootHash: b4a.toString(entryTreeHash, 'hex'),
        signature: b4a.toString(entryProof.upgrade.signature, 'hex')
      }
    }
  }

  const payload = Buffer.from(JSON.stringify(envelope) + '\n')

  if (
    opts.exactBytes &&
    !opts.head &&
    payload.byteLength > 0 &&
    typeof opts.reserveResponseBytes === 'function' &&
    opts.reserveResponseBytes(payload.byteLength) !== true
  ) {
    sendJson({ error: 'Gateway byte-rate limit exceeded' }, 429, {
      'Retry-After': String(opts.egressRetryAfterSeconds || 60)
    })
    return
  }

  res.setHeader('Content-Type', HC_BLOCK_MEDIA_TYPE_JSON)
  res.setHeader('X-Hyper-Key', keyHex)
  res.setHeader('X-Served-By', 'hiverelay-gateway')
  res.setHeader('X-Content-Type-Options', 'nosniff')
  res.setHeader('Cache-Control', opts.exactBytes ? 'no-store, max-age=0' : 'public, max-age=60')
  res.setHeader('X-Hive-Drive-Version', String(driveVersion))
  if (opts.exactBytes) {
    res.setHeader('X-Hive-App-Key', keyHex)
    res.setHeader('X-Hive-Byte-Mode', 'verified')
    res.setHeader('Vary', 'Host')
    res.setHeader('Origin-Agent-Cluster', '?1')
  }
  res.setHeader('Content-Length', payload.byteLength)
  res.writeHead(200)
  if (opts.head) {
    res.end()
    return
  }
  gateway._totalBytesServed += payload.byteLength
  res.end(payload)
  gateway.emit('served', { keyHex, filePath, bytes: payload.byteLength })
}