lib_control.js

/**
 * @module bulb/control
 */

'use strict';

const { createHmac, randomBytes } = require('node:crypto');
const { Transform: TransformStream } = require('node:stream');
const { readFileSync } = require('node:fs');
const { EventEmitter } = require('node:events');
const { ControlCommand } = require('./commands');
const { ControlReply } = require('./replies');


class TorControl extends EventEmitter {

  static get CLIENT_HASH() {
    return 'Tor safe cookie authentication controller-to-server hash';
  }

  /**
   * Creates the challenge response from a SAFECOOKIE challenge
   * @param {string} cookie - The secret cookie string
   * @param {string} clientNonce - Client nonce sent with auth challenge
   * @param {string} serverNonce - Server nonce reply from auth challenge
   * @returns {string}
   */
  static createChallengeResponse(cookie, clientNonce, serverNonce) {
    return createHmac('sha256', TorControl.CLIENT_HASH)
      .update(Buffer.concat([
        Buffer.from(cookie, 'hex'),
        Buffer.from(clientNonce, 'hex'),
        Buffer.from(serverNonce, 'hex')
      ]))
      .digest('hex');
  }

  /**
   * Creates a message splitter from incoming socket data
   * @static
   */
  static createReplySplitter() {
    return new TransformStream({
      objectMode: true,
      transform: function(data, enc, next) {
        let reply = [];
        let lines = data.toString().split('\r\n');

        for (let line of lines) {
          reply.push(line);

          if (line[3] === ' ') {
            this.push(reply);
            reply = [];
          }
        }

        next(null);
      }
    });
  }

  /**
   * Fired when the underlying socket encounters an error
   * @event module:bulb/control~TorControl#error
   * @type {Error}
   */

  /**
   * Fires when the controller is authenticated and ready to send commands
   * @event module:bulb/control~TorControl#ready
   * @type {module:bulb/control~TorControl}
   */

  /**
   * Fires when the underlying socket closes
   * @event module:bulb/control~TorControl#close
   */

  static get DEFAULTS() {
    return {
      authOnConnect: true
    };
  }

  /**
   * @constructor
   * @param {Socket} socket - net.Socket connected to Tor's control port
   * @param {object} [options]
   * @param {boolean} [options.authOnConnect=true] - Automatically authenticate
   */
  constructor(socket, options = TorControl.DEFAULTS) {
    super();

    this._opts = options;
    this._stack = [];

    this.socket = socket
      .on('connect', () => this._handleConnect())
      .on('error', (err) => this._handleError(err))
      .on('close', () => this._handleClose());

    this.socket.pipe(TorControl.createReplySplitter())
      .on('data', (data) => this._handleReply(data));
  }

  /**
   * Handles authentication routine on socket connect
   * @private
   */
  async _authOnConnect() {
    const clientNonce = randomBytes(32).toString('hex');
    const { cookie, methods: authTypes } = await this._getAuthCookie();
    
    let challenge;

    if (authTypes.includes('SAFECOOKIE')) {
      challenge = await this.getAuthChallenge(clientNonce);
    }
  
    if (!challenge || !(challenge.hash && challenge.nonce)) {
      return this.authenticate(cookie);
    }

    const { hash, nonce } = challenge;

    return this.authenticate(TorControl.createChallengeResponse(
      cookie,
      clientNonce,
      nonce,
      hash
    ));
  }

  /**
   * Handles authentication upon socket connection
   * @private
   */
  async _handleConnect() {
    if (this._opts.authOnConnect) {
      try {
        await this._authOnConnect();
      } catch (e) {
        return this.emit('error', e);
      }
    }
      
    this.emit('ready');
  }

  /**
   * Handles errors on the underlying socket and bubbles them
   * @private
   * @param {object} error
   */
  _handleError(err) {
    this.emit('error', err);
  }

  /**
   * Handles message processing and parsing from the socket
   * @private
   * @param {buffer} data
   */
  _handleReply(data) {
    let code = parseInt(data[0].substr(0, 3));
    let lines = data
      .filter((line) => !!line)
      .map((line) => line.substr(4).trim());

    switch (code.toString()[0]) {
      case '2':
        let reply = this._stack.pop();

        if (!reply) {
          return;
        }

        let { method, callback } = reply;
        let parsed = ControlReply[method]
          ? ControlReply[method](lines)
          : new ControlReply({ reply: lines });
        callback(null, parsed);
        break;
      case '4':
      case '5':
        this._stack.pop().callback(new Error(lines[0]));
        break;
      case '6':
      default:
        let event = lines[0].split(' ')[0];
        lines[0] = lines[0].replace(`${event} `, '');
        this.emit(event, lines);
    }
  }

  /**
   * Handles socket close event and bubbles it
   * @private
   */
  _handleClose() {
    this.emit('close');
  }

  /**
   * Send an arbitrary command and pass response to callback
   * @private
   * @param {string} command
   * @param {function} callback
   */
  _send(command) {
    return new Promise((resolve, reject) => {
      function __callback(err, result) {
        if (err) {
          reject(err);
        } else {
          resolve(result);
        }
      };
      command = command.toString();

      this._stack.unshift({ method: command.split(' ')[0], callback: __callback });
      this.socket.write(`${command}\r\n`);
    });
  }

  /**
   * Load the authentication cookie
   * @private
   */
  _getAuthCookie() {
    return this.getProtocolInfo().then((info) => {
      return {
        cookie: info.auth.cookieFile
            ? readFileSync(info.auth.cookieFile).toString('hex')
            : '',
        methods: info.auth.methods
      };
    });
  }

  /**
   * Authenticates with the control port given the supplied param
   * @param {string} token
   */
  authenticate(token) {
    return this._send(ControlCommand.AUTHENTICATE(token));
  }

  /**
   * Requests an authentication challenge from tor
   * @param {string} nonce - Client nonce for authenticating
   */
  getAuthChallenge(nonce) {
    return this._send(ControlCommand.AUTHCHALLENGE(nonce));
  }

  /**
   * Ask tor for general information
   */
  getProtocolInfo() {
    return this._send(ControlCommand.PROTOCOLINFO());
  }

  /**
   * Establishes a hidden service on the given target
   * @param {array} ports - Array containing optional virtualPort (defaults to 80) and target ip:port string
   * @param {object} [options] - {@link module:commands#ADD_ONION}
   */
  createHiddenService(ports, options) {
    return this._send(ControlCommand.ADD_ONION(ports, options));
  }

  /**
   * Takes down a running hidden service owned by this controller
   * @param {string} serviceId - Tor hidden service ID
   */
  destroyHiddenService(serviceId) {
    return this._send(ControlCommand.DEL_ONION(serviceId));
  }

  /**
   * Change the value for a configuration variable
   * @param {string} keyword - Configuration key
   * @param {string} value - New value to set
   */
  setConfig(keyword, value) {
    return this._send(ControlCommand.SETCONF(keyword, value));
  }

  /**
   * Change the value for a configuration variable to it's default
   * @param {string} keyword - Configuration key
   */
  resetConfig(keyword) {
    return this._send(ControlCommand.RESETCONF(keyword));
  }

  /**
   * Return the values for the given configuration key
   * @param {string} keyword - Configuration key
   */
  getConfig(keyword) {
    return this._send(ControlCommand.GETCONF(keyword));
  }

  /**
   * Tell Tor to write out it's config value to it's torrc
   */
  saveConfig() {
    return this._send(ControlCommand.SAVECONF());
  }

  /**
   * Reloads the config values set
   */
  reloadConfig() {
    return this.signal(ControlCommand.Signal.RELOAD);
  }

  /**
   * Controlled shutdown signal
   */
  shutdown() {
    return this.signal(ControlCommand.Signal.SHUTDOWN);
  }

  /**
   * Dump stats to tor log file
   */
  dumpStats() {
    return this.signal(ControlCommand.Signal.DUMP);
  }

  /**
   * Set open logs to debug level
   */
  enableDebug() {
    return this.signal(ControlCommand.Signal.DEBUG);
  }

  /**
   * Shutdown tor immediately
   */
  halt() {
    return this.signal(ControlCommand.Signal.HALT);
  }

  /**
   * Forget client side hostname->ip cache
   */
  clearDnsCache() {
    return this.signal(ControlCommand.Signal.CLEARDNSCACHE);
  }

  /**
   * Clears DNS cache and establishes new clean circuits
   */
  cleanCircuits() {
    return this.signal(ControlCommand.Signal.NEWNYM);
  }

  /**
   * Dumps a heartbeat message to the logs
   */
  dumpHeartbeat() {
    return this.signal(ControlCommand.Signal.HEARTBEAT);
  }

  /**
   * Sends a signal to the control port
   * @param {string} signal
   */
  signal(sig) {
    return this._send(ControlCommand.SIGNAL(sig));
  }

  /**
   * Instruct Tor to route requests to the target to the replacement
   * @param {string} target - Original address to map
   * @param {string} replacement - New address to route request to target
   */
  createAddressMapping(target, replacement) {
    return this._send(ControlCommand.MAPADDRESS(target, replacement));
  }

  /**
   * Creates a new circuit, returning the newly created circuit ID
   * @param {string} [purpose="general"] - The circuit purpose, either general|controller
   */
  createCircuit(purpose) {
    return this._send(ControlCommand.EXTENDCIRCUIT('0', purpose));
  }

  /**
   * Extends the existing circuit
   * @param {string} circuitId - The circuit ID to extend
   */
  extendCircuit(id) {
    return this._send(ControlCommand.EXTENDCIRCUIT(id));
  }

  /**
   * Sets the purpose of the given circuit
   * @param {string} circuitId - The identifier for the circuit
   * @param {string} purpose - One of general|controller
   */
  setCircuitPurpose(circuitId, purpose) {
    return this._send(ControlCommand.SETCIRCUITPURPOSE(circuitId, purpose));
  }

  /**
   * Attaches the specified stream to the given circuit
   * @param {string} streamId - ID for the stream to attach
   * @param {string} [circuitId=0] - Circuit to attach stream
   * @param {number} [hopNumber] - Which hop to exit circuit
   */
  attachStream(streamId, options = { circuitId: '0', hopNumber: null }) {
    return this._send(ControlCommand.ATTACHSTREAM(streamId, options));
  }

  /**
   * Inform the server about a new descriptor
   * @param {object} descriptor - Key-value pairs for server descriptor
   * @param {object} [options]
   * @param {string} [options.purpose="general"] - general|controller|bridge
   * @param {boolean} [options.cache=true] - Flag for caching descriptor
   */
  postDescriptor(descriptor, options) {
    return this._send(ControlCommand.POSTDESCRIPTOR(descriptor, options));
  }

  /**
   * Change the exit address on a given stream
   * @param {string} streamId - ID for stream to redirect
   * @param {string} address - Exit address for the given stream
   * @param {number} [port] - Exit port for the given stream
   */
  redirectStream(streamId, address, port) {
    return this._send(ControlCommand.REDIRECTSTREAM(streamId, address, port));
  }

  /**
   * Closes the exit for the given stream
   * @param {string} streamId - ID for the stream to close
   * @param {number} [reason=1] - Reason code for closing stream
   * @see https://gitweb.torproject.org/torspec.git/tree/tor-spec.txt#n1404
   */
  closeStream(streamId, reason = 1) {
    return this._send(ControlCommand.CLOSESTREAM(streamId, reason));
  }

  /**
   * Closes the given circuit
   * @param {string} circuitId - ID for the circuit to close
   * @param {object} [options]
   * @param {boolean} [options.ifUnused=false] - Only close if not in use
   */
  closeCicuit(circuitId, options = { ifUnused: false }) {
    return this._send(ControlCommand.CLOSECIRCUIT(circuitId, options));
  }

  /**
   * Tells Tor to hang up on the controller
   */
  quit() {
    return this._send(ControlCommand.QUIT());
  }

  /**
   * Launch remote hostname lookup - answer returnd as async ADDRMAP event
   * @param {string} address - Address to lookup
   * @param {object} [options]
   * @param {boolean} [options.reverse=false] - Perform reverse lookup
   */
  resolve(address, options = { reverse: false }) {
    return this._send(ControlCommand.RESOLVE(address, options.reverse));
  }

  /**
   * Instruct Tor to load the configuration file from the given text
   * @param {string} configText - Complete torrc config text to load
   */
  loadConfig(configText) {
    return this._send(ControlCommand.LOADCONF(configText));
  }

  /**
   * Take ownership of the tor process - will close tor when the connection
   * closes
   */
  takeOwnership() {
    return this._send(ControlCommand.TAKEOWNERSHIP())
      .then(this.resetConfig('__OwningControllerProcess'));
  }

  /**
   * Tells the server to drop all guard nodes. Do not invoke this command
   * lightly; it can increase vulnerability to tracking attacks over time.
   */
  dropGuards() {
    return this._send(ControlCommand.DROPGUARDS());
  }

  /**
   * Fetches descriptors for the given hidden service
   * @param {string} serviceId - ID for the hidden service
   * @param {string} [serverLongName] - Long name for specific server to use
   */
  fetchHiddenServiceDescriptor(serviceId, serverLongName) {
    return this._send(ControlCommand.HSFETCH(serviceId, serverLongName));
  }

  /**
   * Launch a hidden service descriptor upload
   * @param {string} descriptor
   * @param {string} [serverLongName] - Long name for specific server to use
   * @see https://gitweb.torproject.org/torspec.git/tree/rend-spec.txt#n193
   */
  postHiddenServiceDescriptor(descriptor, serverLongName = '') {
    return this._send(ControlCommand.HSPOST(descriptor, serverLongName));
  }

  /**
   * Get information from Tor not stored in configuration
   * @param {string} keyword - Keyword for info to fetch
   * @see https://gitweb.torproject.org/torspec.git/tree/control-spec.txt#n500
   */
  getInfo(keyword) {
    return this._send(ControlCommand.GETINFO(keyword));
  }

  /**
   * Instructs Tor to send asynchronous events for the given types - these
   * events will be emitted from the controller. Calling this method resets
   * previously set event listeners
   * @param {string[]} events - List of event types to listen for
   * @see https://gitweb.torproject.org/torspec.git/tree/control-spec.txt#n1708
   */
  addEventListeners(events) {
    return this._send(ControlCommand.SETEVENTS(events));
  }

  /**
   * Instructs Tor to stop listening for events
   */
  removeEventListeners() {
    return this._send(ControlCommand.SETEVENTS([]));
  }

}

module.exports.TorControl = TorControl;