OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / tor-enrollment.test.js

281 lines · javascript ref:
import test from 'brittle'
import sodium from 'sodium-universal'
import b4a from 'b4a'
import fs from 'fs'
import os from 'os'
import path from 'path'
import net from 'net'
import { EventEmitter } from 'events'
import { randomBytes } from 'crypto'
import {
  generateClientAuthKeypair,
  createEnrollment,
  verifyReceipt
} from 'p2p-hiverelay/transports/tor/auth-keys.js'
import { completeOnionEnrollment, restrictedDiscoveryActive } from 'p2p-hiverelay/transports/tor/enrollment.js'
import { TorTransport } from 'p2p-hiverelay/transports/tor/index.js'
import { RelayNode } from 'p2p-hiverelay/core/relay-node/index.js'

const SERVICE_ID = 'a'.repeat(56)
const KEY_BLOB = 'ED25519-V3:' + Buffer.alloc(64, 7).toString('base64')

function ed25519Keypair () {
  const publicKey = b4a.alloc(sodium.crypto_sign_PUBLICKEYBYTES)
  const secretKey = b4a.alloc(sodium.crypto_sign_SECRETKEYBYTES)
  sodium.crypto_sign_keypair(publicKey, secretKey)
  return { publicKey, secretKey, publicKeyHex: b4a.toString(publicKey, 'hex') }
}

/** Daemon-free control-port fake (same shape as tor-auth-keys.test.js). */
class FakeControl extends EventEmitter {
  constructor () {
    super()
    this.commands = []
    this.destroyed = false
  }

  async connect () {}

  cmd (command) {
    this.commands.push(command)
    const head = command.split(' ')[0]
    if (head === 'AUTHENTICATE') return Promise.resolve('250 OK')
    if (head === 'GETINFO') return Promise.resolve('250-version=0.4.9.6\n250 OK')
    if (head === 'ADD_ONION') {
      const lines = ['250-ServiceID=' + SERVICE_ID]
      if (command.startsWith('ADD_ONION NEW:')) lines.push('250-PrivateKey=' + KEY_BLOB)
      lines.push('250 OK')
      return Promise.resolve(lines.join('\n'))
    }
    return Promise.resolve('250 OK')
  }

  destroy () { this.destroyed = true }
}

async function fakeSocks (t) {
  const server = net.createServer((s) => s.destroy())
  await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve()))
  t.teardown(() => { try { server.close() } catch {} })
  return server.address().port
}

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

/** Started transport in restricted-discovery mode (keyFile + rosterFile). */
async function startedTransport (t, dir, opts = {}) {
  const socksPort = await fakeSocks(t)
  const control = new FakeControl()
  const tt = new TorTransport({
    socksPort,
    localPort: 9100,
    keyFile: path.join(dir, 'hs-key.blob'),
    rosterFile: path.join(dir, 'auth-roster.json'),
    endpointKeyId: 'onion-2026-07-a',
    _controlFactory: () => control,
    ...opts
  })
  await tt.start()
  t.teardown(async () => { await tt.stop() })
  return { tt, control }
}

function enrollmentFor ({ client, relayPubkeyHex, kp, now = Date.now(), ttlMs = 30 * 24 * 3600 * 1000 }) {
  return createEnrollment({
    clientIdentity: client.publicKeyHex,
    clientSecretKey: client.secretKey,
    relayPubkey: relayPubkeyHex,
    onionAuthPubX25519: kp.publicKeyB32,
    createdAtMs: now,
    expiresAtMs: now + ttlMs
  })
}

test('pairing-channel enrollment — verify, roster add + persist, rebuild, signed receipt', async (t) => {
  const dir = tmpdir(t)
  const { tt, control } = await startedTransport(t, dir)
  const relay = ed25519Keypair()
  const client = ed25519Keypair()
  const kp = generateClientAuthKeypair()
  const now = Date.now()

  const envelope = enrollmentFor({ client, relayPubkeyHex: relay.publicKeyHex, kp, now })
  const result = await completeOnionEnrollment({
    torTransport: tt,
    relayKeyPair: relay,
    devicePubkeyHex: client.publicKeyHex,
    envelope,
    deviceName: 'phone'
  })

  t.is(result.enrolled, true)
  t.is(tt.onionAddress, SERVICE_ID + '.onion', 'address unchanged across rebuild')

  // roster add + persist via rosterFile (expiry + label from the envelope)
  t.alike(tt.listAuthClients(), [kp.publicKeyB32])
  const saved = JSON.parse(fs.readFileSync(path.join(dir, 'auth-roster.json'), 'utf8'))
  t.is(saved.keys.length, 1)
  t.is(saved.keys[0].pub, kp.publicKeyB32)
  t.is(saved.keys[0].name, 'phone')
  t.is(saved.keys[0].expiresAtMs, envelope.expiresAtMs)

  // service rebuilt in place with the new ClientAuthV3 set
  const rebuild = control.commands.filter((c) => c.startsWith('ADD_ONION')).pop()
  t.ok(rebuild.includes('Flags=V3Auth'))
  t.ok(rebuild.includes('ClientAuthV3=' + kp.publicKeyB32))
  t.ok(control.commands.some((c) => c.startsWith('DEL_ONION')))

  // receipt binds key ↔ onion address ↔ expiry, signed by the relay identity
  const vr = verifyReceipt(result.receipt, {
    expectedRelayPubkey: relay.publicKeyHex,
    expectedClientIdentity: client.publicKeyHex
  })
  t.is(vr.ok, true)
  t.is(vr.receipt.status, 'accepted')
  t.is(vr.receipt.onionAddress, tt.onionAddress)
  t.is(vr.receipt.endpointKeyId, 'onion-2026-07-a')
  t.is(vr.receipt.expiresAtMs, envelope.expiresAtMs)
})

test('enrollment rejections — bad sig, expired, wrong relay, identity mismatch', async (t) => {
  const dir = tmpdir(t)
  const { tt, control } = await startedTransport(t, dir)
  const relay = ed25519Keypair()
  const client = ed25519Keypair()
  const kp = generateClientAuthKeypair()
  const now = Date.now()
  const addCount = () => control.commands.filter((c) => c.startsWith('ADD_ONION')).length

  const run = (envelope, devicePubkeyHex = client.publicKeyHex) =>
    completeOnionEnrollment({ torTransport: tt, relayKeyPair: relay, devicePubkeyHex, envelope })

  // tampered body → signature no longer covers it
  const good = enrollmentFor({ client, relayPubkeyHex: relay.publicKeyHex, kp, now })
  t.is((await run({ ...good, nonce: 'ff'.repeat(32) })).reason, 'bad-signature')

  // expired envelope
  const stale = enrollmentFor({ client, relayPubkeyHex: relay.publicKeyHex, kp, now: now - 200000, ttlMs: 100000 })
  t.is((await run(stale)).reason, 'expired')

  // addressed to a different relay
  const wrongRelay = enrollmentFor({ client, relayPubkeyHex: ed25519Keypair().publicKeyHex, kp, now })
  t.is((await run(wrongRelay)).reason, 'wrong-relay')

  // valid envelope, but the paired device is not the signer
  const stranger = ed25519Keypair()
  t.is((await run(good, stranger.publicKeyHex)).reason, 'identity-mismatch')

  // rejections never touch the roster or the live service
  t.is(addCount(), 1, 'no rebuild beyond initial creation')
  t.alike(tt.listAuthClients(), [])
  t.absent(fs.existsSync(path.join(dir, 'auth-roster.json')), 'nothing persisted')
})

test('enrollment gating — clean no-op without tor / without restricted discovery', async (t) => {
  const dir = tmpdir(t)
  const relay = ed25519Keypair()
  const client = ed25519Keypair()
  const kp = generateClientAuthKeypair()
  const envelope = enrollmentFor({ client, relayPubkeyHex: relay.publicKeyHex, kp })

  t.is(restrictedDiscoveryActive(null), false)
  t.is(restrictedDiscoveryActive({ rosterFile: 'x', clientAuthKeys: [] }), true)
  t.is(restrictedDiscoveryActive({ rosterFile: null, clientAuthKeys: ['a'.repeat(52)] }), true)
  t.is(restrictedDiscoveryActive({ rosterFile: null, clientAuthKeys: [] }), false)

  // no transport at all
  const none = await completeOnionEnrollment({ torTransport: null, relayKeyPair: relay, devicePubkeyHex: client.publicKeyHex, envelope })
  t.alike(none, { enrolled: false, reason: 'tor-disabled' })

  // transport constructed but not running
  const idle = new TorTransport({})
  const notRunning = await completeOnionEnrollment({ torTransport: idle, relayKeyPair: relay, devicePubkeyHex: client.publicKeyHex, envelope })
  t.is(notRunning.reason, 'tor-disabled')

  // running but open discovery — every client already decrypts the descriptor
  const socksPort = await fakeSocks(t)
  const control = new FakeControl()
  const open = new TorTransport({ socksPort, localPort: 9100, keyFile: path.join(dir, 'hs-key.blob'), _controlFactory: () => control })
  await open.start()
  t.teardown(async () => { await open.stop() })
  const openResult = await completeOnionEnrollment({ torTransport: open, relayKeyPair: relay, devicePubkeyHex: client.publicKeyHex, envelope })
  t.alike(openResult, { enrolled: false, reason: 'open-discovery' })
})

test('RelayNode pairDevice — enrollment envelope rides the pairing extras', async (t) => {
  const dir = tmpdir(t)
  // v1 mount-safety: an explicit storage path must already exist on the
  // configured filesystem (Track A fail-closed proof) — pre-create it.
  const storageDir = path.join(dir, 'relay')
  fs.mkdirSync(storageDir, { recursive: true })
  const node = new RelayNode({
    mode: 'private',
    storage: storageDir,
    enableAPI: false,
    enableServices: false,
    discovery: { mdns: false }
  })
  t.teardown(async () => { if (node.running) await node.stop() })
  await node.start()

  // Stand in the tor transport (no daemon in unit tests), restricted discovery on
  const socksPort = await fakeSocks(t)
  const control = new FakeControl()
  const tt = new TorTransport({
    socksPort,
    localPort: 9100,
    keyFile: path.join(dir, 'hs-key.blob'),
    rosterFile: path.join(dir, 'auth-roster.json'),
    _controlFactory: () => control
  })
  await tt.start()
  node.torTransport = tt

  const relayPubkeyHex = b4a.toString(node.swarm.keyPair.publicKey, 'hex')
  const device = ed25519Keypair()
  const kp = generateClientAuthKeypair()
  const envelope = enrollmentFor({ client: device, relayPubkeyHex, kp })

  const events = []
  node.on('tor-enrollment', (e) => events.push(e))

  const pairing = node.enablePairing({ timeoutMs: 10000 })
  const res = await node.pairDevice(pairing.token, device.publicKeyHex, 'phone', { onionEnrollment: envelope })
  t.is(res.paired, true)
  t.is(res.onionEnrollment.enrolled, true)

  const vr = verifyReceipt(res.onionEnrollment.receipt, {
    expectedRelayPubkey: relayPubkeyHex,
    expectedClientIdentity: device.publicKeyHex
  })
  t.is(vr.ok, true)
  t.is(vr.receipt.onionAddress, tt.onionAddress)
  t.alike(tt.listAuthClients(), [kp.publicKeyB32])
  const saved = JSON.parse(fs.readFileSync(path.join(dir, 'auth-roster.json'), 'utf8'))
  t.is(saved.keys[0].pub, kp.publicKeyB32)
  t.is(events.length, 1)
  t.is(events[0].enrolled, true)
  t.is(events[0].pubkey, device.publicKeyHex)

  // legacy callers (no envelope) keep the plain boolean shape
  const pairing2 = node.enablePairing({ timeoutMs: 10000 })
  const paired = await node.pairDevice(pairing2.token, randomBytes(32).toString('hex'), 'laptop')
  t.is(paired, true)

  // bad token with extras → structured failure, no enrollment attempt
  node.enablePairing({ timeoutMs: 10000 })
  const rejected = await node.pairDevice('deadbeef', device.publicKeyHex, 'phone', { onionEnrollment: envelope })
  t.is(rejected.paired, false)
  t.is(rejected.onionEnrollment, null)
  t.is(events.length, 1, 'no enrollment event for a failed pair')

  // enrollment against a tor-less node no-ops cleanly
  node.torTransport = null
  const skipped = await node.enrollOnionAuthClient(device.publicKeyHex, envelope)
  t.alike(skipped, { enrolled: false, reason: 'tor-disabled' })
})