OverviewFilesCommitsIssuesDocsReleasesPeers

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

315 lines · javascript ref:
/**
 * GIGA DoD cross-feature journey — namespace × tor
 * (docs/GIGA-RELEASE-ARCHITECTURE.md §7 "Cross-feature journeys":
 * "wake hints over the onion control path into namespaced outboxes";
 * docs/NAMESPACE.md: "encrypted wake/head hints are exactly the bounded
 * control messages the Tor/Nym privacy lanes carry; the log bodies
 * themselves stay on native paths").
 *
 * What this proves (composition, not internals):
 *   1. Advertisement: the relay's signed capability doc — itself fetched
 *      through the onion read plane — carries the health-gated
 *      privacyTransports entry whose supports name the namespaced-outbox
 *      control messages ('outbox.wake', 'outbox.read').
 *   2. Route selection (model level): the privacy-policy resolver picks
 *      the tor onion lane for a wake/head-hint intent (control-only
 *      coverage, source-ip-hidden, hidden-onion), keeps log bodies on the
 *      native direct path (full coverage), and fails CLOSED when the onion
 *      lane is unavailable and the policy is 'deny' — no silent downgrade.
 *   3. Delivery: a head hint for a blind-namespace outbox really traverses
 *      the onion — client TorTransport → SOCKS → peer vport → Noise XK →
 *      the relay's service-RPC channel — and comes back bounded (well
 *      under the notify payload budget) and opaque (a version number, no
 *      record content), while the sealed body never needed the onion.
 *
 * Realization: real RelayNode + real OutboxLogApp (in-memory persistence;
 * the hypercore journal's RelayNode boot path is broken on fresh stores —
 * see the GAP test in journey-namespace-blind.test.js) + real
 * OnionPeerListener Noise upgrade + real service-RPC wire on a hyperswarm
 * testnet; the tor daemon is faked at the protocol level
 * (helpers/fake-tor.js). Body routing is asserted at the resolver level
 * per the DoD model — the hint is the piece that must traverse the onion.
 */

import test from 'brittle'
import createTestnet from '@hyperswarm/testnet'
import NoiseSecretStream from '@hyperswarm/secret-stream'
import Protomux from 'protomux'
import sodium from 'sodium-universal'
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 { serviceMessageEncoding } from 'p2p-hiverelay/core/services/protocol.js'
import { SERVICES_PROTOCOL_NAME } from 'p2p-hiverelay/core/constants.js'
import {
  PATH_DIRECT,
  PATH_TOR_ONION,
  candidatesFromCapabilityDoc,
  resolvePath
} from 'p2p-hiverelay-client/privacy-policy.js'
import {
  OutboxLogApp,
  canonicalOutboxRecord,
  createOutboxBlindSealAAD,
  createOutboxBlindSealKey,
  sealOutboxBlindPayload
} from 'p2p-hiveservices/builtin/outboxlog/index.js'
import { startFakeTorDaemon } from './helpers/fake-tor.js'

const PEER_VPORT = 19737
const READ_VPORT = 80
const NS = 'wakeapp'
// The bounded control-message budget: notify's max push payload (the wake
// class this lane was built to carry).
const BOUNDED_HINT_BYTES = 3072
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

const HINT_INTENT = Object.freeze({
  transportPrivacy: 'source-ip-hidden',
  relayLocation: 'hidden-onion',
  metadataShaping: 'none',
  pathCoverage: 'control-only',
  downgradePolicy: 'deny'
})

const BODY_INTENT = Object.freeze({
  transportPrivacy: 'direct',
  relayLocation: 'exposed',
  metadataShaping: 'none',
  pathCoverage: 'full',
  downgradePolicy: 'deny'
})

function tmpdir (t) {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hiverelay-journey-namespace-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`)
}

function writerKeyPair (seedByte) {
  const publicKey = b4a.alloc(32)
  const secretKey = b4a.alloc(64)
  sodium.crypto_sign_seed_keypair(publicKey, secretKey, b4a.alloc(32, seedByte))
  return { publicKey, secretKey, publicKeyHex: b4a.toString(publicKey, 'hex') }
}

function signRecord (writer, fields = {}, type = 'head') {
  const namespace = fields._ns || NS
  const data = {
    id: fields.id || writer.publicKeyHex,
    author: writer.publicKeyHex,
    ...fields,
    _k: writer.publicKeyHex,
    _dk: fields._dk || 'd'.repeat(64),
    _ns: namespace,
    _alg: 'ed25519'
  }
  const signed = `pear.app.${data._dk}:${data._ns}:${canonicalOutboxRecord(type, data)}`
  const signature = b4a.alloc(64)
  sodium.crypto_sign_detached(signature, b4a.from(signed, 'utf8'), writer.secretKey)
  return { ...data, _sig: b4a.toString(signature, 'hex') }
}

/** Minimal client half of the relay's service-RPC wire (hiverelay-services). */
function openServiceRpc (stream) {
  const mux = Protomux.from(stream)
  const channel = mux.createChannel({ protocol: SERVICES_PROTOCOL_NAME, id: b4a.from('services-v1') })
  if (!channel) throw new Error('service channel allocation failed')
  const pending = new Map()
  let nextId = 1
  const msg = channel.addMessage({
    encoding: serviceMessageEncoding,
    onmessage: (m) => {
      const waiter = pending.get(m.id)
      if (!waiter) return
      pending.delete(m.id)
      clearTimeout(waiter.timer)
      if (m.type === 2) waiter.resolve(m.result)
      else waiter.reject(new Error(m.error || 'service error'))
    }
  })
  channel.open()
  return {
    call (service, method, params, timeoutMs = 10_000) {
      const id = nextId++
      return new Promise((resolve, reject) => {
        const timer = setTimeout(() => {
          pending.delete(id)
          reject(new Error('service call timeout'))
        }, timeoutMs)
        pending.set(id, { resolve, reject, timer })
        msg.send({ type: 1, id, service, method, params })
      })
    }
  }
}

/** HTTP/1.0 GET through the onion read plane. */
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 statusLine = raw.subarray(0, idx).toString().split('\r\n')[0]
  return { status: Number(statusLine.split(' ')[1]), body: raw.subarray(idx + 4) }
}

test('journey namespace × tor: wake/head hint rides the onion control lane, bodies stay native', 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: true,
    plugins: [],
    // The shipped operator default (config/default.js) — a relay serving app
    // clients over P2P. Grants connecting peers the 'authenticated-user'
    // role the outboxlog routes require at the application router.
    serviceDefaultPeerRole: 'authenticated-user',
    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()
  await waitFor('tor health ready', () => relay.torTransport.health === 'ready')

  // Real OutboxLogApp in the relay's real ServiceRegistry (in-memory
  // persistence — the hypercore journal's RelayNode boot path is broken on
  // fresh stores; see the GAP note in journey-namespace-blind.test.js).
  const outboxApp = new OutboxLogApp({
    persistence: false,
    namespaces: {
      [NS]: { blind: true, caps: { maxOutboxes: 4, maxEntriesPerOutbox: 8, maxValueBytes: 16 * 1024 } }
    }
  })
  relay.serviceRegistry.register(outboxApp)
  await relay.serviceRegistry.startAll(relay._buildServiceContext())
  t.is(relay.serviceRegistry.services.get('outboxlog')?.status, 'running', 'outboxlog service running inside the relay')
  // The relay's application router snapshots registry routes at start()
  // (registerFromRegistry); refresh it so the late-registered provider is
  // dispatchable over the service RPC wire.
  relay.router.registerFromRegistry(relay.serviceRegistry)

  // Operator-side state: one blind-namespace outbox with one sealed head row.
  const writer = writerKeyPair(51)
  const outbox = relay.serviceRegistry.services.get('outboxlog').provider
  outbox.sync.create(writer.publicKeyHex, { namespace: NS })
  const sealKey = createOutboxBlindSealKey()
  const aad = createOutboxBlindSealAAD({ namespace: NS, appId: writer.publicKeyHex, type: 'head', id: writer.publicKeyHex, keyId: 'wake-room@1' })
  const headBody = sealOutboxBlindPayload({ head: 'sealed head material' }, { key: sealKey, aad, keyId: 'wake-room@1' })
  const headRecord = signRecord(writer, { id: writer.publicKeyHex, _ns: NS, body: headBody }, 'head')
  t.alike(outbox.sync.append(writer.publicKeyHex, { type: 'head', data: headRecord }), { ok: true, key: 'head!' + writer.publicKeyHex })
  const sealedCiphertext = headBody.sealed.ciphertext

  // Client tor transport against the same fake daemon.
  const clientTor = new TorTransport({ socksPort: tor.socksPort })
  t.teardown(async () => { try { await clientTor.stop() } catch {} })
  await clientTor.start()

  // ─── 1. Advertisement: capability doc over the onion read plane ───────
  const docRes = await httpGetOverOnion(clientTor, tor.onionAddress, READ_VPORT, '/api/capabilities')
  t.is(docRes.status, 200, 'capability doc fetched through the onion read plane')
  const doc = JSON.parse(docRes.body.toString())
  const onionEntry = doc.privacyTransports && doc.privacyTransports.find((e) => e.id === PATH_TOR_ONION)
  t.ok(onionEntry, 'health-gated onion advertisement present while ready')
  t.ok(onionEntry.supports.includes('outbox.wake'), 'advertisement names the wake-hint control message')
  t.ok(onionEntry.supports.includes('outbox.read'), 'advertisement names the outbox read control message')

  // ─── 2. Route selection: hint → onion, body → native, deny fails closed
  const candidates = candidatesFromCapabilityDoc(doc, { direct: true, tor: true })
  t.ok(candidates.some((c) => c.id === PATH_TOR_ONION && c.available), 'resolver sees a usable onion path')

  const hintRoute = resolvePath(HINT_INTENT, candidates)
  t.is(hintRoute.selectedTransport, PATH_TOR_ONION, 'wake/head hint routes over the onion control lane')
  t.ok(hintRoute.satisfied.includes('pathCoverage:control-only'), 'hint is evidence-backed as control-only')
  t.ok(hintRoute.satisfied.includes('relayLocation:hidden-onion'))
  t.absent(hintRoute.downgraded, 'no downgrade needed for the hint')

  const bodyRoute = resolvePath(BODY_INTENT, candidates)
  t.is(bodyRoute.selectedTransport, PATH_DIRECT, 'log bodies stay on the native path')
  t.is(bodyRoute.coverage, 'full', 'body class needs full byte coverage — not the bounded lane')

  const closed = resolvePath(HINT_INTENT, candidatesFromCapabilityDoc(doc, { direct: true, tor: false }))
  t.is(closed.selectedTransport, null, 'onion down + deny policy → fail closed, never a silent downgrade')
  t.ok(closed.unsatisfied.includes('no-satisfying-path'))

  // ─── 3. Delivery: the head hint really traverses the onion ────────────
  const serverSide = new Promise((resolve) => {
    relay.torTransport.on('connection', (stream, info) => resolve(info))
  })
  const relayPub = relay.swarm.keyPair.publicKey
  const torStream = await clientTor.connect(tor.onionAddress, PEER_VPORT)
  t.teardown(() => { try { torStream.destroy() } catch {} })
  const noise = new NoiseSecretStream(true, torStream, {
    keyPair: NoiseSecretStream.keyPair(),
    remotePublicKey: relayPub,
    pattern: 'XK'
  })
  t.teardown(() => { try { noise.destroy() } catch {} })
  await new Promise((resolve, reject) => {
    noise.once('handshake', resolve)
    noise.once('error', reject)
  })
  const info = await Promise.race([serverSide, sleep(5000).then(() => null)])
  t.ok(info && info.isOnion === true, 'peer-plane connection arrived over the onion')

  const rpc = openServiceRpc(noise)
  const hint = await rpc.call('outboxlog', 'heads', { appIds: [writer.publicKeyHex] })
  t.is(hint.heads[writer.publicKeyHex], 1, 'head hint delivered: the namespaced outbox head version')

  const hintBytes = Buffer.byteLength(JSON.stringify(hint))
  t.ok(hintBytes <= BOUNDED_HINT_BYTES, `hint is bounded control traffic (${hintBytes}B <= ${BOUNDED_HINT_BYTES}B)`)
  t.alike(Object.keys(hint), ['heads'], 'hint carries versions only — no record material')
  t.absent(JSON.stringify(hint).includes(sealedCiphertext), 'no ciphertext rides the wake lane')

  // …while the body it points at stays on the native path: the sealed head
  // row is served by the namespace engine without ever traversing the onion.
  const stored = outbox.sync.get(writer.publicKeyHex, 'head!' + writer.publicKeyHex)
  t.ok(stored && stored.body && stored.body.sealed, 'body remains sealed at the relay')
  t.is(stored.body.sealed.ciphertext, sealedCiphertext, 'body content untouched by the hint round-trip')
})