OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / integration / journey-gateway-tor.test.js

210 lines · javascript ref:
/**
 * GIGA DoD cross-feature journey — gateway × tor
 * (docs/GIGA-RELEASE-ARCHITECTURE.md §7 "Cross-feature journeys":
 * "public app served via gateway while custody entries hard-403; same
 * relay reachable over onion").
 *
 * What this proves (composition, not internals):
 *   1. The onion read-plane vport (80) forwards to the relay's HTTP
 *      API/gateway port — asserted on the actual ADD_ONION the relay's
 *      TorTransport issued from config.
 *   2. A public-tier drive seeded on the relay serves exact bytes through
 *      the onion gateway path (/v1/hyper/<key>/<path>), fetched over the
 *      tor transport's SOCKS connect.
 *   3. A blind (custody-class) drive seeded on the SAME relay is a hard
 *      403 through the SAME onion read plane — identical to clearnet
 *      behavior ("Private app — encrypted content, P2P access only").
 *   4. The relay's signed capability doc, fetched through the same onion
 *      read plane, carries the health-gated privacyTransports onion
 *      advertisement (only present while health === ready).
 *
 * Realization: real RelayNode + real HyperGateway on a hyperswarm testnet,
 * drives authored + seeded through the production seed path (same pattern
 * as test/integration/gateway-streaming.test.js); tor daemon faked at the
 * protocol level (helpers/fake-tor.js) — bytes really traverse the
 * transport's SOCKS stream into the relay's HTTP stack.
 */

import test from 'brittle'
import createTestnet from '@hyperswarm/testnet'
import b4a from 'b4a'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { RelayNode } from 'p2p-hiverelay/core/relay-node/index.js'
import { TorTransport } from 'p2p-hiverelay/transports/tor/index.js'
import { ONION_READ_PLANE_CSP } from 'p2p-hiverelay/gateway/edge-headers.js'
import { startFakeTorDaemon } from './helpers/fake-tor.js'

const READ_VPORT = 80
const TEST_MAX_STORAGE_BYTES = 64 * 1024 * 1024
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

function tmpdir (t) {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hiverelay-journey-gateway-tor-'))
  t.teardown(() => { try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} })
  return dir
}

function pickPort () {
  return 49000 + Math.floor(Math.random() * 10000)
}

async function waitFor (label, fn, timeoutMs = 15_000) {
  const start = Date.now()
  while (Date.now() - start < timeoutMs) {
    const value = await fn()
    if (value) return value
    await sleep(100)
  }
  throw new Error(`${label} timed out after ${timeoutMs}ms`)
}

/** HTTP/1.0 GET through the onion read plane; parses status/headers/body. */
async function httpGetOverOnion (transport, onionAddress, vport, requestPath) {
  const stream = await transport.connect(onionAddress, vport)
  const raw = await new Promise((resolve, reject) => {
    const chunks = []
    stream.on('data', (d) => chunks.push(d))
    stream.on('end', () => resolve(Buffer.concat(chunks)))
    stream.on('error', reject)
    stream.write(`GET ${requestPath} HTTP/1.0\r\nHost: ${onionAddress}\r\nConnection: close\r\n\r\n`)
  })
  const idx = raw.indexOf('\r\n\r\n')
  const head = raw.subarray(0, idx).toString()
  const lines = head.split('\r\n')
  const headers = {}
  for (const line of lines.slice(1)) {
    const split = line.indexOf(':')
    if (split > 0) headers[line.slice(0, split).trim().toLowerCase()] = line.slice(split + 1).trim()
  }
  return {
    status: Number(lines[0].split(' ')[1]),
    headers,
    body: raw.subarray(idx + 4)
  }
}

test('journey gateway × tor: public bytes through the onion read plane, blind content hard-403 on the same plane', async (t) => {
  const dir = tmpdir(t)
  const relayStorage = path.join(dir, 'relay')
  fs.mkdirSync(relayStorage, { recursive: true })

  const testnet = await createTestnet(3)
  const tor = await startFakeTorDaemon(t)
  const apiPort = pickPort()

  const relay = new RelayNode({
    storage: relayStorage,
    bootstrapNodes: testnet.bootstrap,
    enableAPI: true,
    apiPort,
    apiHost: '127.0.0.1',
    enableRelay: false,
    enableSeeding: true,
    enableServices: false,
    enableNetworkDiscovery: false,
    enableHolesail: false,
    transports: { udp: true, tor: true },
    tor: {
      socksPort: tor.socksPort,
      controlPort: tor.controlPort,
      keyFile: path.join(dir, 'relay-tor', 'hs-key.blob'),
      cookieAuthFile: path.join(dir, 'relay-tor', 'control_auth_cookie'),
      peer: { port: 0 }
    }
  })
  t.teardown(async () => {
    try { await relay.stop() } catch {}
    try { await testnet.destroy() } catch {}
  })
  await relay.start()

  // ─── Seed a public drive and a blind drive via the production seed path
  // Author through namespace-isolated sessions (two keyless Hyperdrives on
  // one root session deadlock at ready()), then close the writers before
  // the relay adopts the keys through the production seed path.
  const Hyperdrive = (await import('hyperdrive')).default
  const publicHtml = b4a.from('<!doctype html><h1>public over onion</h1>\n')
  const blindCiphertext = b4a.from('sealed-ciphertext-the-gateway-must-never-serve\n')

  const publicWriter = new Hyperdrive(relay.store.namespace('public-app'))
  await publicWriter.ready()
  await publicWriter.put('/index.html', publicHtml)
  const publicKeyHex = b4a.toString(publicWriter.key, 'hex')
  await publicWriter.close()

  const blindWriter = new Hyperdrive(relay.store.namespace('blind-app'))
  await blindWriter.ready()
  await blindWriter.put('/index.html', blindCiphertext)
  const blindKeyHex = b4a.toString(blindWriter.key, 'hex')
  await blindWriter.close()

  await relay.seedApp(publicKeyHex, { privacyTier: 'public', maxStorage: TEST_MAX_STORAGE_BYTES })
  const anchored = await waitFor('public drive anchored', () => relay.appRegistry.get(publicKeyHex)?.anchored === true)
  t.ok(anchored, 'public drive seeded through the production path')

  await relay.seedApp(blindKeyHex, {
    privacyTier: 'p2p-only',
    blind: true,
    maxStorage: TEST_MAX_STORAGE_BYTES
  })
  t.is(relay.seededApps.get(blindKeyHex)?.blind, true, 'blind drive registered as blind/custody-class')

  await waitFor('tor health ready', () => relay.torTransport.health === 'ready')

  // ─── 1. The onion read plane forwards to the gateway/API port ─────────
  const addOnion = tor.addOnionCommands().pop()
  t.ok(addOnion.includes('Port=80,127.0.0.1:' + apiPort),
    'ADD_ONION maps vport 80 to the relay API/gateway port')

  const clientTor = new TorTransport({ socksPort: tor.socksPort })
  t.teardown(async () => { try { await clientTor.stop() } catch {} })
  await clientTor.start()

  // ─── 2. Public drive serves exact bytes through the onion ─────────────
  const pub = await httpGetOverOnion(clientTor, tor.onionAddress, READ_VPORT, '/v1/hyper/' + publicKeyHex + '/index.html')
  t.is(pub.status, 200, 'public drive serves through the onion gateway path')
  t.ok(pub.body.equals(Buffer.from(publicHtml)), 'exact bytes through the onion read plane')

  // ─── 2a. R3/R5/R6/R7 edge policy on the onion read plane ─────────────
  t.is(pub.headers['content-security-policy'], ONION_READ_PLANE_CSP,
    'onion ingress carries the documented restrictive CSP default')
  t.is(pub.headers['cross-origin-opener-policy'], 'same-origin')
  t.is(pub.headers['cross-origin-resource-policy'], 'cross-origin',
    'path-lane CORP matches the compatibility posture, even over onion')
  t.is(pub.headers['referrer-policy'], 'no-referrer',
    'the key/path being read never leaks via Referer')
  t.is(pub.headers.link, `<hive://${publicKeyHex}/index.html>; rel="canonical"`,
    'capable clients get the canonical hive:// upgrade hint through the onion')
  t.absent(pub.headers['service-worker-allowed'],
    'no service-worker scope escape on the shared onion origin')

  // ─── 3. Blind content is a hard 403 through the same onion plane ──────
  const blind = await httpGetOverOnion(clientTor, tor.onionAddress, READ_VPORT, '/v1/hyper/' + blindKeyHex + '/index.html')
  t.is(blind.status, 403, 'blind/custody content refused on the onion read plane')
  const blindBody = JSON.parse(blind.body.toString())
  t.is(blindBody.blind, true, '403 names the blind class (clearnet parity)')
  t.ok(/P2P access only/.test(blindBody.error), 'fail-closed message, not content: ' + blindBody.error)
  t.absent(blind.body.includes(blindCiphertext), 'no ciphertext leaks through the gateway')
  t.is(blind.headers['content-security-policy'], ONION_READ_PLANE_CSP,
    'the onion CSP default covers refusals too')
  t.is(blind.headers.link, `<hive://${blindKeyHex}/index.html>; rel="canonical"`,
    'the refusal and the upgrade hint agree: use the native P2P transport')

  // ─── 4. Capability doc over the onion: health-gated advertisement ─────
  const docRes = await httpGetOverOnion(clientTor, tor.onionAddress, READ_VPORT, '/api/capabilities')
  t.is(docRes.status, 200, 'capability doc reachable through the onion read plane')
  const doc = JSON.parse(docRes.body.toString())
  t.ok(Array.isArray(doc.privacyTransports) && doc.privacyTransports.length === 1,
    'privacyTransports advertised while tor health is ready')
  const onionEntry = doc.privacyTransports[0]
  t.is(onionEntry.id, 'tor-v3-onion-v1')
  t.is(onionEntry.addresses[0].address, tor.onionAddress)
  t.alike(onionEntry.vports, [80, 19737], 'read plane + peer plane both advertised')
  t.is(onionEntry.vportRoles.readPlane, 80)
  t.is(onionEntry.vportRoles.peer, 19737)
  t.is(doc.onionGatewayUrl, 'http://' + tor.onionAddress + ':' + apiPort,
    'doc binds the onion read-plane ingress for gateway clients')
})