OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / tor-auth-keys.test.js

248 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 {
  base32Encode,
  base32Decode,
  isValidClientPub,
  generateClientAuthKeypair,
  clientInstallCommand,
  dotAuthLine,
  canonicalize,
  createEnrollment,
  verifyEnrollment,
  createReceipt,
  verifyReceipt,
  OnionRosterStore,
  ENROLLMENT_TYPE,
  RECEIPT_TYPE
} from 'p2p-hiverelay/transports/tor/auth-keys.js'
import { TorTransport } from 'p2p-hiverelay/transports/tor/index.js'

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

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

test('base32 encode/decode round-trip + known vector', async (t) => {
  t.is(base32Encode(b4a.alloc(32, 0)), 'a'.repeat(52))
  const kp = generateClientAuthKeypair()
  t.is(kp.publicKeyB32.length, 52)
  t.ok(isValidClientPub(kp.publicKeyB32))
  t.alike([...base32Decode(kp.publicKeyB32)], [...kp.publicKey])
  t.absent(isValidClientPub('A'.repeat(52))) // uppercase not our canonical form
  t.absent(isValidClientPub('a'.repeat(51)))
})

test('keypair formats for tor wire protocols', async (t) => {
  const kp = generateClientAuthKeypair()
  t.is(kp.publicKey.length, 32)
  t.is(kp.secretKey.length, 32)
  t.ok(/^[A-Za-z0-9+/]{43}$/.test(kp.secretKeyB64)) // base64 no padding, 43 chars
})

test('clientInstallCommand matches ONION_CLIENT_AUTH_ADD wire shape', async (t) => {
  const kp = generateClientAuthKeypair()
  const cmd = clientInstallCommand({ onionAddress: 'x'.repeat(56) + '.onion', secretKeyB64: kp.secretKeyB64, clientName: 'dev1' })
  t.ok(cmd.startsWith('ONION_CLIENT_AUTH_ADD ' + 'x'.repeat(56) + ' x25519:'))
  t.ok(cmd.includes('ClientName=dev1'))
  t.ok(cmd.endsWith('Flags=Permanent'))
})

test('dotAuthLine is the 3-field filesystem form', async (t) => {
  const kp = generateClientAuthKeypair()
  t.is(dotAuthLine(kp.publicKeyB32), 'descriptor:x25519:' + kp.publicKeyB32)
  t.exception(() => dotAuthLine('nope'), /invalid x25519/)
})

test('canonicalize is key-order independent', async (t) => {
  const a = canonicalize({ b: 1, a: { d: [3, 2], c: 'x' } })
  const b = canonicalize({ a: { c: 'x', d: [3, 2] }, b: 1 })
  t.is(a, b)
  t.is(a, '{"a":{"c":"x","d":[3,2]},"b":1}')
})

test('enrollment sign/verify lifecycle', async (t) => {
  const client = ed25519Keypair()
  const relay = ed25519Keypair()
  const kp = generateClientAuthKeypair()
  const now = Date.now()

  const env = createEnrollment({
    clientIdentity: client.publicKeyHex,
    clientSecretKey: client.secretKey,
    relayPubkey: relay.publicKeyHex,
    onionAuthPubX25519: kp.publicKeyB32,
    createdAtMs: now,
    expiresAtMs: now + 100000,
    nonce: 'ab'.repeat(32)
  })
  t.is(env.type, ENROLLMENT_TYPE)
  t.is(env.signature.length, 128)

  const ok = verifyEnrollment(env, { expectedRelayPubkey: relay.publicKeyHex, nowMs: now })
  t.is(ok.ok, true)

  // wrong relay audience
  t.is(verifyEnrollment(env, { expectedRelayPubkey: ed25519Keypair().publicKeyHex, nowMs: now }).reason, 'wrong-relay')
  // tampered field
  const tampered = { ...env, createdAtMs: now + 1 }
  t.is(verifyEnrollment(tampered, { expectedRelayPubkey: relay.publicKeyHex, nowMs: now }).reason, 'bad-signature')
  // wrong signer
  const forged = createEnrollment({
    clientIdentity: client.publicKeyHex,
    clientSecretKey: ed25519Keypair().secretKey,
    relayPubkey: relay.publicKeyHex,
    onionAuthPubX25519: kp.publicKeyB32,
    createdAtMs: now,
    expiresAtMs: now + 100000
  })
  t.is(verifyEnrollment(forged, { expectedRelayPubkey: relay.publicKeyHex, nowMs: now }).reason, 'bad-signature')
  // expired
  t.is(verifyEnrollment(env, { expectedRelayPubkey: relay.publicKeyHex, nowMs: now + 200000 }).reason, 'expired')
  // bad type
  t.is(verifyEnrollment({ ...env, type: 'x' }, { nowMs: now }).reason, 'wrong-type')
})

test('receipt sign/verify binds address, keyId, expiry', async (t) => {
  const relay = ed25519Keypair()
  const client = ed25519Keypair()
  const kp = generateClientAuthKeypair()
  const now = Date.now()

  const receipt = createReceipt({
    relayPubkey: relay.publicKeyHex,
    relaySecretKey: relay.secretKey,
    status: 'accepted',
    onionAddress: 'b'.repeat(56) + '.onion',
    endpointKeyId: 'onion-2026-07-a',
    clientIdentity: client.publicKeyHex,
    onionAuthPubX25519: kp.publicKeyB32,
    enrolledAtMs: now,
    expiresAtMs: now + 100000
  })
  t.is(receipt.type, RECEIPT_TYPE)

  const ok = verifyReceipt(receipt, { expectedRelayPubkey: relay.publicKeyHex, expectedClientIdentity: client.publicKeyHex })
  t.is(ok.ok, true)
  t.is(ok.receipt.endpointKeyId, 'onion-2026-07-a')

  t.is(verifyReceipt({ ...receipt, onionAddress: 'c'.repeat(56) + '.onion' }, {}).reason, 'bad-signature')
  t.is(verifyReceipt(receipt, { expectedRelayPubkey: ed25519Keypair().publicKeyHex }).reason, 'wrong-relay')
  t.is(verifyReceipt(receipt, { expectedClientIdentity: ed25519Keypair().publicKeyHex }).reason, 'wrong-client')
  t.exception(() => createReceipt({ status: 'maybe' }), /accepted\|rejected/)
})

test('roster store — persist, reload, revoke, purge, 0600', async (t) => {
  const dir = tmpdir(t)
  const file = path.join(dir, 'tor', 'auth-roster.json')
  const alice = generateClientAuthKeypair().publicKeyB32
  const bob = generateClientAuthKeypair().publicKeyB32
  const now = Date.now()

  const store = await new OnionRosterStore(file).load()
  store.add(alice, { name: 'alice', nowMs: now })
  store.add(bob, { nowMs: now, expiresAtMs: now + 1000 })
  await store.save()
  t.is(fs.statSync(file).mode & 0o777, 0o600)

  const reloaded = await new OnionRosterStore(file).load()
  t.alike(new Set(reloaded.activeKeys({ nowMs: now })), new Set([alice, bob]))
  t.alike(reloaded.activeKeys({ nowMs: now + 2000 }), [alice]) // bob expired

  t.is(reloaded.revoke(alice, { nowMs: now }), true)
  t.is(reloaded.revoke(alice, { nowMs: now }), false) // idempotent
  t.alike(reloaded.activeKeys({ nowMs: now }), [bob]) // alice revoked, bob still live

  // purge drops revoked/expired past grace
  t.is(reloaded.purge({ nowMs: now + 8 * 24 * 3600 * 1000 }), 2)
  t.alike(reloaded.activeKeys({ nowMs: now }), [])

  // corrupt file fails closed
  fs.writeFileSync(file, '{broken')
  await t.exception(() => new OnionRosterStore(file).load(), /corrupt onion roster/)
})

// --- TorTransport roster persistence integration ---

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

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
}

test('TorTransport rosterFile — add persists, reload restores, service gets union at start', async (t) => {
  const socksPort = await fakeSocks(t)
  const dir = tmpdir(t)
  const rosterFile = path.join(dir, 'auth-roster.json')
  const keyFile = path.join(dir, 'hs-key.blob')
  const alice = generateClientAuthKeypair().publicKeyB32
  const bob = generateClientAuthKeypair().publicKeyB32

  const c1 = new FakeControl()
  const tt1 = new TorTransport({ socksPort, localPort: 9100, keyFile, rosterFile, _controlFactory: () => c1 })
  await tt1.start()
  await tt1.addAuthClient(alice)
  await tt1.addAuthClient(bob)
  const saved = JSON.parse(fs.readFileSync(rosterFile, 'utf8'))
  t.is(saved.keys.length, 2)
  await tt1.stop()

  // fresh instance: roster loads from disk and is bound at service creation
  const c2 = new FakeControl()
  const tt2 = new TorTransport({ socksPort, localPort: 9100, keyFile, rosterFile, _controlFactory: () => c2 })
  await tt2.start()
  const add = c2.commands.filter((c) => c.startsWith('ADD_ONION')).pop()
  t.ok(add.includes('ClientAuthV3=' + alice))
  t.ok(add.includes('ClientAuthV3=' + bob))
  t.alike(new Set(tt2.listAuthClients()), new Set([alice, bob]))

  // revoke persists and rebuilds
  await tt2.removeAuthClient(alice)
  const saved2 = JSON.parse(fs.readFileSync(rosterFile, 'utf8'))
  t.is(saved2.keys.find((k) => k.pub === alice).revokedAtMs > 0, true)
  const rebuild = c2.commands.filter((c) => c.startsWith('ADD_ONION')).pop()
  t.absent(rebuild.includes('ClientAuthV3=' + alice))
  t.ok(rebuild.includes('ClientAuthV3=' + bob))
  await tt2.stop()
})