OverviewFilesCommitsIssuesDocsReleasesPeers

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

180 lines · javascript ref:
/**
 * GIGA DoD cross-feature journey — blind × tor
 * (docs/GIGA-RELEASE-ARCHITECTURE.md §7 "Cross-feature journeys":
 * custody traffic to a relay reachable over its Tor onion endpoint).
 *
 * What this proves (composition, not internals):
 *   1. A RelayNode with the tor transport enabled binds its OnionPeerListener
 *      and advertises BOTH vports through the (faked) control port — read
 *      plane 80 and peer plane 19737 — and reaches verified-ready health.
 *   2. A client that dials the onion peer vport (SOCKS → fake-daemon
 *      forwarding → OnionPeerListener) completes the Noise XK upgrade and
 *      lands in the relay's normal connection handler, which attaches the
 *      custody/publish protocol channels to that stream.
 *   3. The custody path is reachable through the onion: a properly
 *      publisher-signed custody intent submitted over the channel is
 *      validated and appended to the relay's seeding registry.
 *   4. ONION-INV-006 — reachability ≠ authority: the SAME channel refuses
 *      a forged (bad-signature) intent. The transport granted reachability
 *      (v3 auth/Noise), never service authorization.
 *
 * Realization: real RelayNode + real OnionPeerListener + real Noise XK +
 * real publish-channel request/response on a hyperswarm testnet; the tor
 * daemon is faked at the protocol level (control + SOCKS, see
 * helpers/fake-tor.js) so the whole thing runs headless — the same mocking
 * seam as test/unit/tor-transport.test.js, one layer out.
 */

import test from 'brittle'
import createTestnet from '@hyperswarm/testnet'
import NoiseSecretStream from '@hyperswarm/secret-stream'
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 { PublishProtocolClient } from 'p2p-hiverelay/core/protocol/publish-channel.js'
import { createCustodyIntent, hashHex } from 'p2p-hiverelay/core/custody-signing.js'
import { startFakeTorDaemon } from './helpers/fake-tor.js'

const PEER_VPORT = 19737
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

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

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`)
}

test('journey blind × tor: custody ops over the onion peer vport — reachability ≠ authority (ONION-INV-006)', 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 relay = new RelayNode({
    storage: relayStorage,
    bootstrapNodes: testnet.bootstrap,
    enableAPI: false,
    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'),
      // No cookie on disk → the transport falls back to bare AUTHENTICATE,
      // which the fake control port accepts.
      cookieAuthFile: path.join(dir, 'relay-tor', 'control_auth_cookie'),
      peer: { port: 0 } // ephemeral loopback bind; the vport mapping materializes after bind
    }
  })
  t.teardown(async () => {
    try { await relay.stop() } catch {}
    try { await testnet.destroy() } catch {}
  })
  await relay.start()

  // ─── 1. Dual vports advertised; health-gated readiness reached ────────
  t.ok(relay.torTransport, 'relay constructed its tor transport from config')
  t.ok(relay.torTransport.running, 'tor transport started headless against the fake daemon')
  await waitFor('tor health ready', () => relay.torTransport.health === 'ready')
  t.is(relay.torTransport.onionAddress, tor.onionAddress, 'persistent v3 identity from the control plane')

  const addOnion = tor.addOnionCommands().pop()
  t.ok(addOnion.includes('Port=80,127.0.0.1:9100'), 'read-plane vport forwarded (apiPort default)')
  t.ok(addOnion.includes('Port=19737,127.0.0.1:' + relay.torTransport.peerListener.port),
    'peer vport 19737 forwarded to the bound OnionPeerListener')

  // The server side must see the inbound peer connection as onion-sourced.
  const serverSide = new Promise((resolve) => {
    relay.torTransport.on('connection', (stream, info) => resolve(info))
  })

  // ─── 2. Client dials the onion peer vport; Noise XK upgrade ───────────
  const clientTor = new TorTransport({ socksPort: tor.socksPort })
  t.teardown(async () => { try { await clientTor.stop() } catch {} })
  await clientTor.start()

  const clientKP = NoiseSecretStream.keyPair()
  const relayPub = relay.swarm.keyPair.publicKey
  const relayPubHex = b4a.toString(relayPub, 'hex')

  const torStream = await clientTor.connect(tor.onionAddress, PEER_VPORT)
  t.teardown(() => { try { torStream.destroy() } catch {} })
  const noise = new NoiseSecretStream(true, torStream, {
    keyPair: clientKP,
    remotePublicKey: relayPub,
    pattern: 'XK'
  })
  t.teardown(() => { try { noise.destroy() } catch {} })
  await new Promise((resolve, reject) => {
    noise.once('handshake', resolve)
    noise.once('error', reject)
  })
  t.alike([...noise.remotePublicKey], [...relayPub], 'client authenticated the relay identity over Noise XK')

  const info = await Promise.race([serverSide, sleep(5000).then(() => null)])
  t.ok(info && info.isOnion === true && info.type === 'tor', 'relay connection handler received it as an onion peer')

  // ─── 3. Custody path reachable through the onion ──────────────────────
  const publisher = NoiseSecretStream.keyPair()
  const publish = new PublishProtocolClient({ submitTimeoutMs: 10_000 })
  t.teardown(() => { try { publish.destroy() } catch {} })
  t.ok(publish.attach(noise, relayPubHex), 'publish channel attaches to the onion stream')
  await Promise.race([
    new Promise((resolve) => publish.once('channel-open', resolve)),
    sleep(5000).then(() => { throw new Error('publish channel never opened over the onion') })
  ])

  const intent = createCustodyIntent({
    addressKey: b4a.toString(NoiseSecretStream.keyPair().publicKey, 'hex'),
    blindContentId: hashHex({ journey: 'blind-tor', n: 1 }),
    ciphertextRoot: b4a.toString(NoiseSecretStream.keyPair().publicKey, 'hex'),
    contentVersion: 1,
    requiredReplicas: 1,
    deadline: Date.now() + 60_000,
    retainUntil: Date.now() + 3_600_000,
    shardPolicy: 'all'
  }, publisher)

  const accepted = await publish.submit(relayPubHex, 'intent', intent)
  t.ok(accepted.ok, 'signed custody intent accepted through the onion: ' + JSON.stringify(accepted))
  const stored = await waitFor('intent in registry', () => {
    try { return relay.seedingRegistry.getCustodyIntent(intent.intentId) } catch { return null }
  })
  t.is(stored.blindContentId, intent.blindContentId, 'registry appended the onion-submitted intent')

  // ─── 4. ONION-INV-006: the transport granted reachability, not authority
  // Same channel, same Noise session — a forged intent (valid shape, bad
  // signature) must still be refused by the custody layer.
  const forged = {
    ...intent,
    intentId: hashHex({ journey: 'blind-tor', forged: true }),
    blindContentId: hashHex({ journey: 'blind-tor', forged: true })
  }
  const refused = await publish.submit(relayPubHex, 'intent', forged)
  t.absent(refused.ok, 'forged intent refused through the same onion channel')
  t.ok(/INVALID_CUSTODY_ENTRY|bad signature/.test(refused.error || ''),
    'refusal is the custody signature check, not a transport error: ' + refused.error)
  let forgedStored = true
  try { forgedStored = relay.seedingRegistry.getCustodyIntent(forged.intentId) } catch { forgedStored = null }
  t.absent(forgedStored, 'forged intent never entered the registry')
})