commit a5230b9105c06f81445f717003c5c04b99af8407 Author: Nicolas Dextraze Date: Sun Jan 10 12:43:30 2021 -0500 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb79dd5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +.idea diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..754e048 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,21 @@ +{ + "requires": true, + "lockfileVersion": 1, + "dependencies": { + "curve25519-js": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz", + "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==" + }, + "futoin-hkdf": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/futoin-hkdf/-/futoin-hkdf-1.3.2.tgz", + "integrity": "sha512-3EVi3ETTyJg5PSXlxLCaUVVn0pSbDf62L3Gwxne7Uq+d8adOSNWQAad4gg7WToHkcgnCJb3Wlb1P8r4Evj4GPw==" + }, + "tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d7dee4d --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "dependencies": { + "curve25519-js": "0.0.4", + "futoin-hkdf": "^1.3.2", + "tweetnacl": "^1.0.3" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..de8c8ac --- /dev/null +++ b/src/index.js @@ -0,0 +1,148 @@ +const fs = require("fs"); +const path = require("path"); +const url = require("url"); +const http = require("http"); +const https = require("https"); +const Consensus = require("./tor/Consensus"); +const OR = require("./tor/OnionRouter"); +const Socket = require("./tor/Socket"); +const { get } = require('./utils/http'); + +const LogLevels = { + off: 0, + error: 1, + warn: 2, + info: 3, + debug: 4 +} +let logLevel = LogLevels.error; +function timestamp() { + const d = new Date().toISOString(); + return [d.substr(5, 5), d.substr(11, 8)].join(' ') +} + +const logger = { + debug(...args) { + if (LogLevels.debug > logLevel) return; + console.debug(timestamp(), "D", ...args); + }, + info(...args) { + if (LogLevels.info > logLevel) return; + console.log(timestamp(), "I", ...args); + }, + warn(...args) { + if (LogLevels.warn > logLevel) return; + console.warn(timestamp(), "W", ...args); + }, + error(...args) { + if (LogLevels.error > logLevel) return; + console.error(timestamp(), "E", ...args); + }, +}; + +(async function main(args) { + let nb_hops = 3; + let arg_url = ''; + for (const arg of args) { + if (arg === '-v') { + logLevel = LogLevels.warn; + } else if (arg === '-vv') { + logLevel = LogLevels.info; + } else if (arg === '-vvv') { + logLevel = LogLevels.debug; + } else if (arg.length === 1 && arg[0] >= '0' && arg[0] <= '9') { + nb_hops = parseInt(arg, 10); + } else { + arg_url = arg; + } + } + if (!arg_url) { + console.log('Wrong usage\nUsage: %s [-v|-vv|-vvv] [nb_hops] url'); + return; + } + + const uri = url.parse(arg_url); + if (!uri.port) { + uri.port = (uri.protocol === 'https:' ? 443 : 80).toString(); + } + + /** @type Socket */ + let socket; + /** @type Circuit */ + let circuit; + try { + const app_cache_path = path.join(process.env.HOME, '.cache', 'mini-tor-js'); + const cached_consensus_path = path.join(app_cache_path, 'cached-consensus') + fs.mkdirSync(app_cache_path, { recursive: true }); + // Load consensus + const consensus = new Consensus(logger); + await consensus.fetch(cached_consensus_path); + consensus.set_allowed_dir_ports(80, 443); + // Connect relays path + const hop_count = () => circuit ? circuit.node_list.length : 0; + const forbidden_routers = []; + while(hop_count() < nb_hops) { + if (hop_count() === 0) { // Entry + // Connect to first OR + const entry_or = consensus.get_random_onion_router_by_criteria({ + or_ports: [80, 443], + flags: OR.valid | OR.running | OR.fast, + forbidden_routers + }); + socket = new Socket(logger); + await socket.connect(entry_or); + if (!socket.is_connected()) { + logger.error("Could not connect to", entry_or.name); + return; + } + circuit = await socket.create_circuit(); + if (!circuit || hop_count() < 1) { + logger.error("Could not create circuit to", entry_or.name); + await socket.close(); + } else { + forbidden_routers.push(entry_or.identity_fingerprint.toString('hex')); + logger.info("Connected to", entry_or.name); + } + } else if (hop_count() === nb_hops - 1) { // Exit + const exit_or = consensus.get_random_onion_router_by_criteria({ + flags: OR.valid | OR.running | OR.fast | OR.exit, + forbidden_routers + }); + const before = hop_count(); + await circuit.extend(exit_or, 'ntor'); + if (hop_count() === before) { + logger.error("Could not create circuit to", exit_or.name); + } else { + logger.info("Extended to exit", exit_or.name); + } + } else { // Middle(s) + const relay_or = consensus.get_random_onion_router_by_criteria({ + flags: OR.valid | OR.running | OR.fast, + forbidden_routers + }); + const before = hop_count(); + await circuit.extend(relay_or, 'ntor'); + if (hop_count() === before) { + logger.error("Could not extend circuit to", relay_or.name); + } else { + forbidden_routers.push(relay_or.identity_fingerprint.toString('hex')); + logger.info("Extended to", relay_or.name); + } + } + } + /** @type TorStream */ + const tor_stream = await circuit.create_stream(uri.hostname, uri.port); + if (!tor_stream) { + logger.error("Could not create stream to", uri.href); + return; + } + const stream = tor_stream.get_stream(); + const result = await get(uri.protocol, uri.hostname, uri.port, uri.path, stream); + console.log(result); + } catch (err) { + logger.error(err); + } finally { + circuit && await circuit.destroy(); + socket && await socket.close(); + } +})(process.argv.slice(2)); diff --git a/src/tor/Cell.js b/src/tor/Cell.js new file mode 100644 index 0000000..0dce1db --- /dev/null +++ b/src/tor/Cell.js @@ -0,0 +1,172 @@ +/** + * @property {number} circuit_id + * @property {number} command + * @property {Buffer} payload + * @class + */ +class Cell { + constructor (circuit_id, command, payload) { + this.circuit_id = circuit_id; + this.command = command; + this.payload = payload; + } + + is_recognized() { + return this.payload[1] === 0 && this.payload[2] === 0; + } + /** + * @param {number} protocol_version + * @returns {Buffer} + */ + get_bytes(protocol_version) { + let cell_bytes; + + if (is_variable_length_cell_command(this.command)) + { + cell_bytes = Buffer.alloc( + // + // circuit id. + // + (protocol_version < 4 ? 2 : 4) + + + // + // Cell command. + // + 1 + + + // + // payload size (16 bits). + // + 2 + + + // + // payload. + // + this.payload.length); + } + else + { + cell_bytes = Buffer.alloc(Cell.size); + } + + // + // tor-spec.txt + // 5.1.1. + // + // In link protocol 3 or lower, CircIDs are 2 bytes long; + // in protocol 4 or higher, CircIDs are 4 bytes long. + // + + let n = 0; + if (protocol_version < 4) + { + n = cell_bytes.writeUInt16BE(this.circuit_id, n); + //cell_buffer.write(static_cast(_circuit_id)); + } + else + { + n = cell_bytes.writeInt32BE(this.circuit_id, n); + } + + n = cell_bytes.writeUInt8(this.command, n); + + if (is_variable_length_cell_command(this.command)) + { + n = cell_bytes.writeUInt16BE(this.payload.length, n); + } + + this.payload.copy(cell_bytes, n); + + return cell_bytes; + } +} +Cell.size = 514; +Cell.payload_size = 509; + +function is_variable_length_cell_command(command) { + return (command === Cell.commands.versions) || (command >= Cell.commands.vpadding); +} +Cell.is_variable_length_cell_command = is_variable_length_cell_command; + +Cell.commands = Object.freeze({ + // + // Cell commands. + // + padding: 0, + create: 1, + created: 2, + relay: 3, + destroy: 4, + create_fast: 5, + created_fast: 6, + versions: 7, + netinfo: 8, + relay_early: 9, + create2: 10, + created2: 11, + + // + // relay commands. + // + relay_begin: 1, + relay_data: 2, + relay_end: 3, + relay_connected: 4, + relay_sendme: 5, + relay_extend: 6, + relay_extended: 7, + relay_truncate: 8, + relay_truncated: 9, + relay_drop: 10, + relay_resolve: 11, + relay_resolved: 12, + relay_begin_dir: 13, + relay_extend2: 14, + relay_extended2: 15, + + // + // rendezvous commands. + // + relay_command_establish_intro: 32, + relay_command_establish_rendezvous: 33, + relay_command_introduce1: 34, + relay_command_introduce2: 35, + relay_command_rendezvous1: 36, + relay_command_rendezvous2: 37, + relay_command_intro_established: 38, + relay_command_rendezvous_established: 39, + relay_command_introduce_ack: 40, + + // + // variable-length Cell commands. + // + vpadding: 128, + certs: 129, + auth_challenge: 130, + authenticate: 131, + authorize: 132, +}); +Cell.commands_lookup = Object.keys(Cell.commands).filter(x => x === 'relay_early' || !x.startsWith('relay_')) + .reduce((a, c) => ({...a, [Cell.commands[c]]: c}), {}); +Cell.relay_commands_lookup = Object.keys(Cell.commands).filter(x => x.startsWith('relay_')) + .reduce((a, c) => ({...a, [Cell.commands[c]]: c}), {}); + +Cell.destroy_reasons = Object.freeze({ + none : 0, // no reason given + protocol : 1, // tor protocol violation + internal : 2, // internal error + requested : 3, // a client sent a TRUNCATE command + hibernating : 4, // not currently operating; trying to save bandwidth + resource_limit : 5, // out of memory, sockets, or circuit IDs + connection_failed : 6, // unable to reach relay + onion_router_identity : 7, // connected to relay, but its OR identity was not as expected + onion_router_connection_closed : 8, // the OR connection that was carrying this circuit died + finished : 9, // the circuit has expired for being dirty or old + timeout : 10, // circuit construction took too long + destroyed : 11, // the circuit was destroyed w/o client TRUNCATE + no_such_service : 12, // request for unknown hidden service +}); +Cell.destroy_reasons_lookup = Object.keys(Cell.destroy_reasons) + .reduce((a,c) => ({...a, [Cell.destroy_reasons[c]]: c}), {}); + +module.exports = Cell; diff --git a/src/tor/Circuit.js b/src/tor/Circuit.js new file mode 100644 index 0000000..6f875a8 --- /dev/null +++ b/src/tor/Circuit.js @@ -0,0 +1,620 @@ +const CircuitNode = require('./CircuitNode'); +const Cell = require('./Cell') +const RelayCell = require('./RelayCell') +const Stream = require('./TorStream') +const { parseIp } = require('../utils/net') +const { defer } = require('../utils/time') + +const States = Object.freeze({ + none:0, + creating:1, + extending:2, + connecting:3, + ready:4, + destroyed:5, + + rendezvous_establishing:6, + rendezvous_established:7, + rendezvous_introducing:8, + rendezvous_introduced:9, + rendezvous_completing:10, + rendezvous_completed:11, +}); + +class Circuit { + static _next_circuit_id = 1; + static _next_stream_id = 1; + + /** @type CircuitNode[] */ + _node_list = []; + /** @type Socket */ + _socket = null; + _circuit_id; + _waits = {}; + /** @type CircuitNode */ + _extend_node = null; + _stream_map = {}; + + constructor (socket, logger) { + this._socket = socket; + this._circuit_id = (Circuit._next_circuit_id++) | 0x80000000; + this._logger = logger; + } + + get circuit_id() { + return this._circuit_id; + } + + get node_list() { + return this._node_list; + } + + get state() { return this._state }; + + set state(state) { + this._state = state; + const waiters = this._waits[state]; + if (waiters) { + let d; + while(d = waiters.pop()) { + clearTimeout(d.timeout); + d.resolve(true); + } + } + } + + close_streams() { + // + // destroy each stream in this circuit. + // + for(const stream_id in this._stream_map) + { + // + // this call removes the stream from our stream map. + // + this.send_relay_end_cell(this._stream_map[stream_id]); + } + + this._node_list = []; + + // + // signal destroy. + // + this.state = States.destroyed; + } + + destroy() { + if (this.state === States.destroyed) + { + return; + } + + this._logger.debug("Circuit.destroy()"); + + if (this._extend_node) + { + this._extend_node = null; + } + + this.close_streams(); + this._socket.remove_circuit(this); + } + + handle_cell(cell) { + switch(cell.command){ + case Cell.commands.created2: + this._on_created2(cell); + break; + case Cell.commands.destroy: + this._on_destroy(cell); + break; + case Cell.commands.relay: + const relay_cell = this._decrypt(cell); + if (!relay_cell.node) { + this._logger.warn(`could not decrypt cell for circuit ${this._circuit_id}. destroying`); + this.destroy(); + return; + } + + this._logger.debug( + "Circuit.handle_cell() [circuit: %i%s, stream: %i, command: %s, relay_command: %s, payload_size: %i]", + relay_cell.circuit_id & 0x7FFFFFFF, + (relay_cell.circuit_id & 0x80000000 ? " (MSB set)" : ""), + relay_cell.stream_id, + Cell.commands_lookup[relay_cell.command], + Cell.relay_commands_lookup[relay_cell.relay_command], + relay_cell.relay_payload.length); + + switch(relay_cell.relay_command) { + case Cell.commands.relay_extended2: + this._on_relay_extended2(relay_cell); + break; + + case Cell.commands.relay_truncated: + this._on_relay_truncated_cell(relay_cell); + break; + + case Cell.commands.relay_end: + this._on_relay_end_cell(relay_cell); + break; + + case Cell.commands.relay_connected: + this._on_relay_connected_cell(relay_cell); + break; + + case Cell.commands.relay_extended: + this._on_relay_extended_cell(relay_cell); + break; + + case Cell.commands.relay_data: + this._on_relay_data_cell(relay_cell); + break; + + case Cell.commands.relay_sendme: + this._on_relay_sendme_cell(relay_cell); + break; + + case Cell.commands.relay_command_rendezvous2: + this._on_relay_extended_cell(relay_cell); + this.state = States.rendezvous_completed; + break; + + case Cell.commands.relay_command_rendezvous_established: + this.state = States.rendezvous_established; + break; + + case Cell.commands.relay_command_introduce_ack: + this.state = States.rendezvous_introduced; + break; + + default: + this._logger.warn( + "Circuit._handle_cell() !! unhandled relay cell [ relay_command: %s ]", + Cell.relay_commands_lookup[relay_cell.relay_command]); + break; + } + break; + default: + this._logger.warn(`unhandled cell for circuit ${this._circuit_id}`, cell); + } + } + + _on_relay_data_cell(cell) { + this._logger.debug('Circuit._on_relay_data_cell():', cell.relay_payload); + // + // decrement deliver window on circuit node. + // + cell.node.decrement_deliver_window(); + if (cell.node.consider_sending_sendme()) { + this.send_relay_sendme_cell(null); + } + + const stream = this.get_stream_by_id(cell.stream_id); + if (stream) { + stream.append_to_recv_buffer(cell.relay_payload); + + // + // decrement window on stream. + // + stream.decrement_deliver_window(); + if (stream.consider_sending_sendme()) { + this.send_relay_sendme_cell(stream); + } + } + } + + _on_relay_sendme_cell(cell) { + if (cell.stream_id === 0) { + cell.node.increment_package_window(); + } else { + const stream = this.get_stream_by_id(cell.stream_id); + if (stream) { + stream.increment_package_window(); + } + } + } + + _on_relay_connected_cell(cell) { + const stream = this.get_stream_by_id(cell.stream_id) + if (stream) { + stream.state = Stream.states.ready; + } + + this.state = States.ready; + } + + _on_relay_truncated_cell(cell) { + // + // tor-spec.txt + // 5.4. + // + // To tear down part of a circuit, the OP may send a RELAY_TRUNCATE cell + // signaling a given OR (Stream ID zero). That OR sends a DESTROY + // cell to the next node in the circuit, and replies to the OP with a + // RELAY_TRUNCATED cell. + // + // [Note: If an OR receives a TRUNCATE cell and it has any RELAY cells + // still queued on the circuit for the next node it will drop them + // without sending them. This is not considered conformant behavior, + // but it probably won't get fixed until a later version of Tor. Thus, + // clients SHOULD NOT send a TRUNCATE cell to a node running any current + // version of Tor if a) they have sent relay cells through that node, + // and b) they aren't sure whether those cells have been sent on yet.] + // + // When an unrecoverable error occurs along one connection in a + // circuit, the nodes on either side of the connection should, if they + // are able, act as follows: the node closer to the OP should send a + // RELAY_TRUNCATED cell towards the OP; the node farther from the OP + // should send a DESTROY cell down the circuit. + // + + this._logger.error("circuit::handle_relay_truncated_cell() destroying circuit"); + this.destroy(); + } + + _on_relay_end_cell(cell) { + const stream = this.get_stream_by_id(cell.stream_id); + if (stream) { + this._logger.debug("circuit::handle_relay_end_cell() [stream: %i, reason: %s]", cell.stream_id, Cell.destroy_reasons_lookup[cell.relay_payload[0]]); + + stream.state = Stream.states.destroyed; + delete this._stream_map[cell.stream_id]; + } + } + + _on_destroy(cell) { + const reason = cell.payload.readUInt8(0); + this._logger.info('Circuit._on_destroy:', Cell.destroy_reasons_lookup[reason]); + this.destroy(); + } + + _on_relay_extended_cell(cell) { + //TODO + } + + _on_relay_extended2(cell) { + // + // The payload of an EXTENDED2 cell is the same as the payload of a + // CREATED2 cell + // + + // + // finish the handshake. + // + const handshake_data = cell.relay_payload.slice(2); + this._extend_node.compute_shared_secret(handshake_data); + + if (this._extend_node.has_valid_crypto_state()) + { + this._node_list.push(this._extend_node); + + // + // we're ready here. + // + this._extend_node = null; + this.state = States.ready; + } + else + { + this._logger.error(`circuit::handle_extended2_cell() extend node [ ${this._extend_node.onion_router.name} ] has invalid crypto state`); + + this.destroy(); + } + } + + _on_created2(cell) { + // + // A CREATED2 cell contains: + // HLEN (Server Handshake Data Len) [2 bytes] + // HDATA (Server Handshake Data) [HLEN bytes] + // + + // + // finish the handshake. + // + const handshake_data = cell.payload.slice(2); + this._extend_node.compute_shared_secret(handshake_data); + + if (this._extend_node.has_valid_crypto_state()) + { + this._node_list.push(this._extend_node); + + // + // we're ready here. + // + this._extend_node = null; + this.state = States.ready; + } + else + { + this._logger.error(`circuit._handle_cell created2 extend node [ ${this._extend_node.onion_router.name} ] has invalid crypto state`); + + this.destroy(); + } + } + + create(first_or, handshake_type) { + switch (handshake_type) { + case 'ntor': + return this._create_ntor(first_or); + default: + throw new Error("Not implemented"); + } + } + + async _create_ntor(first_or) { + await first_or.fetch_descriptor(); + if (!first_or.ntor_onion_key) + { + this._logger.warn(`circuit._create_ntor() [or: ${first_or.name} does not support NTOR handshake]`); + return; + } + + this._logger.debug(`circuit._create_ntor() [or: ${first_or.name}, state: creating]`); + this.state = States.creating; + this._extend_node = new CircuitNode(this, first_or, 'normal'); + + const handshake_bytes = Buffer.alloc(2 + 2 + 84); + + handshake_bytes.writeUInt16BE(2, 0); // ntor type + handshake_bytes.writeUInt16BE(84, 2); // ntor onion skin length + const onion_skin_ntor = this._extend_node.create_onion_skin_ntor(); + onion_skin_ntor.copy(handshake_bytes, 4); + + this._socket.send_cell(new Cell( + this._circuit_id, + Cell.commands.create2, + handshake_bytes + )); + + if (await this.wait_for_state(States.ready)) + { + this._logger.debug(`circuit._create_ntor() [or: ${first_or.name}, state: created]`); + } + else + { + this._logger.error(`circuit._create_ntor() [or: ${first_or.name}, state: destroyed]`); + } + } + + async create_stream(host, port) { + // + // tor-spec.txt + // 6.2. + // + // ADDRPORT [nul-terminated string] + // FLAGS[4 bytes] + // + // ADDRPORT is made of ADDRESS | ':' | PORT | [00] + // + + const host_port = `${host}:${port}` + + const relay_data_bytes = Buffer.alloc(host_port.length + 1); + relay_data_bytes.write(host_port, 0, 'utf8'); + + // + // send RELAY_BEGIN cell. + // + const stream_id = Circuit._next_stream_id++; + + const stream = new Stream(stream_id, this, this._logger); + this._stream_map[stream_id] = stream; + + this._logger.debug("circuit::create_stream() [url: %s, stream: %i, status: creating]", host_port, stream_id); + this.state = States.connecting; + + this.send_relay_cell(stream_id, Cell.commands.relay_begin, relay_data_bytes); + + if (await this.wait_for_state(States.ready)) + { + this._logger.debug("circuit::create_stream() [url: %s, stream: %i, status: created]", host_port, stream_id); + return stream; + } + else + { + this._logger.error("circuit::create_stream() [is_ready() == false]"); + } + } + + extend(or, handshake_type) { + switch (handshake_type) { + case 'ntor': + return this._extend_ntor(or); + default: + throw new Error("Not implemented"); + } + } + + async _extend_ntor(next_onion_router) { + // + // An EXTEND2 cell's relay payload contains: + // NSPEC (Number of link specifiers) [1 byte] + // NSPEC times: + // LSTYPE (Link specifier type) [1 byte] + // LSLEN (Link specifier length) [1 byte] + // LSPEC (Link specifier) [LSLEN bytes] + // HTYPE (Client Handshake Type) [2 bytes] + // HLEN (Client Handshake Data Len) [2 bytes] + // HDATA (Client Handshake Data) [HLEN bytes] + // + // Link specifiers describe the next node in the circuit and how to + // connect to it. Recognized specifiers are: + // [00] TLS-over-TCP, IPv4 address + // A four-byte IPv4 address plus two-byte ORPort + // [01] TLS-over-TCP, IPv6 address + // A sixteen-byte IPv6 address plus two-byte ORPort + // [02] Legacy identity + // A 20-byte SHA1 identity fingerprint. At most one may be listed. + // + + await next_onion_router.fetch_descriptor(); + if (!next_onion_router.ntor_onion_key) + { + this._logger.warn(`circuit._extend_ntor() [or: ${next_onion_router.name} does not support NTOR handshake`); + return; + } + + const ipv4 = 0; + const ipv6 = 1; + const legacy_id = 2; + + this._logger.debug(`circuit::extend_ntor() [or: ${next_onion_router.name}, state: extending]`); + this.state = States.extending; + + this._extend_node = new CircuitNode(this, next_onion_router, 'normal'); + const onion_skin = this._extend_node.create_onion_skin_ntor(); + + const relay_payload_bytes = Buffer.alloc( + 1 + // NSPEC + 1 + 1 + 6 + // NSPEC IPv4 (4 bytes) + port (2 bytes) + 1 + 1 + 20 + // NSPEC identity_fingerprint (20 bytes) + 2 + // HTYPE + 2 + // HLEN + 84); // HDATA + + let n = relay_payload_bytes.writeUInt8(2, 0); // 2x NSPEC + + n = relay_payload_bytes.writeUInt8(ipv4, n); + n = relay_payload_bytes.writeUInt8(6, n); + const ip = parseIp(next_onion_router.ip); + n = relay_payload_bytes.writeInt32BE(ip, n); + n = relay_payload_bytes.writeUInt16BE(next_onion_router.or_port, n); + + n = relay_payload_bytes.writeUInt8(legacy_id, n); + n = relay_payload_bytes.writeUInt8(20, n); + n += next_onion_router.identity_fingerprint.copy(relay_payload_bytes, n); + + n = relay_payload_bytes.writeUInt16BE(2, n); + n = relay_payload_bytes.writeUInt16BE(84, n); + onion_skin.copy(relay_payload_bytes, n); + + this.send_relay_cell( + 0, + Cell.commands.relay_extend2, + relay_payload_bytes, + // + // clients MUST only send + // EXTEND cells inside RELAY_EARLY cells + // + Cell.commands.relay_early, + this._extend_node); + + if (await this.wait_for_state(States.ready)) + { + this._logger.debug(`circuit::extend_ntor() [or: ${next_onion_router.name}, state: extended]`); + } + else + { + this._logger.error(`circuit::extend_ntor() [or: ${next_onion_router.name}, state: destroyed]`); + } + } + + /** + * @returns {CircuitNode} + */ + get_final_circuit_node() { + return this._node_list[this._node_list.length - 1]; + } + + get_stream_by_id(stream_id) { + return this._stream_map[stream_id]; + } + + send_relay_sendme_cell() { + //TODO + } + + send_relay_end_cell(stream) { + this.send_relay_cell( + stream.stream_id, + Cell.commands.relay_end, + Buffer.from([6])); // reason + + // + // signal destroy. + // + stream.state = Stream.states.destroyed; + + delete this._stream_map[stream.stream_id]; + } + + send_relay_data_cell(stream, data) { + for (let i = 0; i < data.length; i += Cell.payload_size) + { + const data_size = Math.min(data.length - i, Cell.payload_size); + + this.get_final_circuit_node().decrement_package_window(); + + this.send_relay_cell( + stream.stream_id, + Cell.commands.relay_data, + data.slice(i, i + data_size)); + } + } + + send_relay_cell(stream_id, relay_command, payload, cell_command = Cell.commands.relay_early, node = null) { + node = node ? node : this.get_final_circuit_node(); + + if (this.get_stream_by_id(stream_id) == null && stream_id !== 0) + { + this._logger.warn("Circuit.send_relay_cell() attempt to send cell to non-existent stream-id:", stream_id); + return; + } + + this._logger.debug( + "Circuit.send_relay_cell() [circuit: %i%s, stream: %i, command: %s, relay_command: %s]", + this._circuit_id & 0x7FFFFFFF, + ((this._circuit_id & 0x80000000) ? " (MSB set)" : ""), + stream_id, + Cell.commands_lookup[cell_command], + Cell.relay_commands_lookup[relay_command], + payload); + + this._socket.send_cell(this._encrypt(new RelayCell( + this._circuit_id, + cell_command, + node, + relay_command, + stream_id, + payload))); + } + + /** + * @param {RelayCell} cell + * @return {Cell} + * @private + */ + _encrypt(cell) { + for (let i = this._node_list.length - 1; i >= 0; i--) { + this._node_list[i].encrypt_forward_cell(cell); + } + return cell; + } + + /** + * @param {Cell} cell + * @return {RelayCell} + * @private + */ + _decrypt(cell) { + for (const node of this._node_list) { + if (node.decrypt_backward_cell(cell)) { + return RelayCell.fromCell(node, cell); + } + } + return new RelayCell(); + } + + wait_for_state(desired_state, timeout = 30000) { + const d = defer(); + d.timeout = setTimeout(() => { + this._logger.debug("Circuit.wait_for_state(): timed out"); + d.resolve(false); + }, timeout); + (this._waits[desired_state] = this._waits[desired_state] || []).push(d); + return d.promise; + } +} + +module.exports = Circuit; diff --git a/src/tor/CircuitNode.js b/src/tor/CircuitNode.js new file mode 100644 index 0000000..481ff12 --- /dev/null +++ b/src/tor/CircuitNode.js @@ -0,0 +1,84 @@ +const KeyAgreementNtor = require('./KeyAgreementNtor'); +const CircuitNodeCryptoState = require('./CircuitNodeCryptoState'); + +class CircuitNode { + _circuit = null + /** @type OnionRouter */ + _onion_router = null; + _type = 'normal'; + /** @type KeyAgreementNtor */ + _handshake = null; + /** @type CircuitNodeCryptoState */ + _crypto_state = null; + + get onion_router() { + return this._onion_router; + } + + constructor (circuit, or, type) { + this._circuit = circuit; + this._onion_router = or; + this._type = type; + } + + create_onion_skin_ntor() { + this._handshake = new KeyAgreementNtor(this._onion_router); + + return Buffer.concat([ + this._onion_router.identity_fingerprint, + this._onion_router.ntor_onion_key, + this._handshake.public_key + ], 20 + 32 + 32); + } + + compute_shared_secret(handshake_data) { + const key_material = this._handshake.compute_shared_secret(handshake_data); + + if (key_material) + { + // + // struct key_material + // { + // byte_type digest_forward [20]; + // byte_type digest_backward[20]; + // byte_type cipher_forward [16]; + // byte_type cipher_backward[16]; + // ^^ sizeof == 40 + 32 == 72 ^^ + // + // byte_type rend_nonce [20]; << ignored now + // ^^ sizeof == 72 + 20 == 92 ^^ << (used in establishing of introduction points, + // rend-spec.txt � 1.2.) + // + // byte_type __garbage__ []; << ignored + // }; + // + + this._crypto_state = new CircuitNodeCryptoState(key_material); + } + } + + has_valid_crypto_state() { + return this._crypto_state != null; + } + + /** + * @param {RelayCell} cell + */ + encrypt_forward_cell(cell) { + return this._crypto_state.encrypt_forward_cell(cell); + } + + /** + * @param {Cell} cell + * @returns {boolean} + */ + decrypt_backward_cell(cell) { + return this._crypto_state.decrypt_backward_cell(cell); + } + + decrement_deliver_window() {} + consider_sending_sendme() {} + increment_package_window() {} + decrement_package_window() {} +} +module.exports = CircuitNode; diff --git a/src/tor/CircuitNodeCryptoState.js b/src/tor/CircuitNodeCryptoState.js new file mode 100644 index 0000000..105015e --- /dev/null +++ b/src/tor/CircuitNodeCryptoState.js @@ -0,0 +1,100 @@ +const crypto = require('crypto'); +const Cell = require('./Cell') + +class CircuitNodeCryptoState { + /** @type Hash */ + _forward_digest = null; + /** @type Hash */ + _backward_digest = null; + /** @type Cipher */ + _backward_cipher = null; + /** @type Cipher */ + _forward_cipher = null; + + constructor (key_material) { + this._forward_digest = crypto.createHash('sha1'); + const df = key_material.slice(0, 20); + this._forward_digest.update(df); + + this._backward_digest = crypto.createHash('sha1'); + const db = key_material.slice(20, 20 + 20); + this._backward_digest.update(db); + + const kf = key_material.slice(20 + 20, 20 + 20 + 16); + this._forward_cipher = crypto.createCipheriv('aes-128-ctr', kf, Buffer.alloc(16)); + + const kb = key_material.slice(20 + 20 + 16, 20 + 20 + 16 + 16); + this._backward_cipher = crypto.createCipheriv('aes-128-ctr', kb, Buffer.alloc(16)); + } + + /** + * @param {RelayCell} cell + */ + encrypt_forward_cell(cell) { + const relay_payload_bytes = Buffer.alloc(Cell.payload_size); + + if (cell.payload == null) + { + relay_payload_bytes.writeUInt8(cell.relay_command, 0); + relay_payload_bytes.writeUInt16BE(0, 1); // 'recognized' + relay_payload_bytes.writeUInt16BE(cell.stream_id, 3); + relay_payload_bytes.writeInt32BE(0, 5); // digest placeholder + relay_payload_bytes.writeUInt16BE(cell.relay_payload.length, 9); + cell.relay_payload.copy(relay_payload_bytes, 11); + + // + // update digest field in the payload + // + this._forward_digest.update(relay_payload_bytes); + const digest = this._forward_digest.copy().digest(); // requires node >= 12.16 + digest.copy(relay_payload_bytes, 5, 0, 4); + } + else + { + cell.payload.copy(relay_payload_bytes, 0); + } + + // + // encrypt the payload + // + cell.payload = this._forward_cipher.update(relay_payload_bytes); + + // console.debug("CircuitNodeCryptoState.encrypt_forward_cell(): %s %s", + // relay_payload_bytes.toString('hex'), + // cell.payload.toString('hex')); + } + + /** + * @param {Cell} cell + * @returns {boolean} + */ + decrypt_backward_cell(cell) { + cell.payload = this._backward_cipher.update(cell.payload); + + // + // check if this is a cell for us. + // + if (cell.is_recognized()) + { + // + // remove the digest from the payload + // + const payload_without_digest = Buffer.alloc(cell.payload.length); + cell.payload.copy(payload_without_digest); + payload_without_digest.writeInt32BE(0, 5); + + const backward_digest_clone = this._backward_digest.copy(); + backward_digest_clone.update(payload_without_digest); + const digest = backward_digest_clone.digest(); + + if (digest.compare(cell.payload, 5, 5 + 4, 0, 4) === 0) + { + this._backward_digest.update(payload_without_digest); + return true; + } + } + + return false; + } +} +module.exports = CircuitNodeCryptoState; diff --git a/src/tor/Consensus.js b/src/tor/Consensus.js new file mode 100644 index 0000000..b29be05 --- /dev/null +++ b/src/tor/Consensus.js @@ -0,0 +1,241 @@ +const fs = require('fs'); +const crypto = require('crypto'); +const {get} = require('../utils/http'); +const OR = require('./OnionRouter'); +const OnionRouter = require('./OnionRouter') + +function authority_onion_router(name, ip, or_port, dir_port) { + return { + name, + ip, + or_port, + dir_port + } +} +const authorities = [ + authority_onion_router("dizum", "194.109.206.212", 443, 80), + authority_onion_router("Serge", "66.111.2.131", 9001, 9030), + authority_onion_router("moria1", "128.31.0.34", 9101, 9131), + //authority_onion_router("tor26", "86.59.21.38", 443, 80}, + authority_onion_router("bastet", "204.13.164.118", 443, 80), + authority_onion_router("maatuska", "171.25.193.9", 80, 443), + authority_onion_router("dannenberg", "193.23.244.244", 443, 80), + authority_onion_router("Faravahar", "154.35.175.225", 443, 80), + authority_onion_router("gabelmoo", "131.188.40.189", 443, 80), + authority_onion_router("longclaw", "199.58.81.140", 443, 80), +] +const router_status_flags = [ + "Authority", + "BadExit", + "Exit", + "Fast", + "Guard", + "HSDir", + "Named", + "NoEdConsensus", + "Stable", + "Running", + "Unnamed", + "Valid", + "V2Dir" +]; + +class Consensus { + _allowed_dir_flags = OR.fast | OR.valid | OR.running | OR.v2dir; + _allowed_dir_ports = []; + _max_try_count = 3; + _onion_router_map = {}; + _valid_until = 0; + + constructor (logger) { + this._logger = logger; + } + + async fetch(cached_consensus_path) { + let have_valid_consensus = false; + let force_download = false; + let consensus_content = ''; + + // + // if no path to the cached consensus file + // was provided, we have to download it. + // + if (!cached_consensus_path || !fs.existsSync(cached_consensus_path)) + { + force_download = true; + } + + while (!have_valid_consensus) + { + consensus_content = force_download + ? await this._download_from_random_router("/tor/status-vote/current/consensus", true) + : fs.readFileSync(cached_consensus_path).toString(); + + // + // assume newly downloaded consensus as valid. + // + const reject_invalid = !force_download; + this._parse_consensus(consensus_content, reject_invalid); + + // + // consider force_download-ed consensus as valid. + // + have_valid_consensus = force_download || (this._valid_until >= Date.now()); + + // + // if the consensus is invalid, we have to download it anyway. + // + if (!have_valid_consensus) + { + force_download = true; + } + } + + this._logger.debug("loaded", Object.keys(this._onion_router_map).length, "routers"); + + // + // save the consensus content, if the path was provided. + // + if (force_download && cached_consensus_path) + { + fs.writeFileSync(cached_consensus_path, consensus_content); + } + } + + set_allowed_dir_ports(...ports) { + this._allowed_dir_ports = [...ports]; + } + + get_onion_routers_by_criteria(criteria) { + const candidates = []; + for (const key in this._onion_router_map) { + const router = this._onion_router_map[key]; + let allowed = true; + if (criteria.dir_ports) { + allowed = allowed && criteria.dir_ports.includes(router.dir_port); + } + if (criteria.or_ports) { + allowed = allowed && criteria.or_ports.includes(router.or_port); + } + if (criteria.flags) { + allowed = allowed && (router.flags & criteria.flags) === criteria.flags; + } + if (criteria.forbidden_routers) { + allowed = allowed && !criteria.forbidden_routers.includes(router.identity_fingerprint.toString('hex')) + } + if (allowed) { + candidates.push(router); + } + } + return candidates; + } + + get_random_onion_router_by_criteria(criteria) { + const candidates = this.get_onion_routers_by_criteria(criteria); + if (candidates.length === 0) { + return null; + } + const idx = crypto.randomInt(candidates.length); + return candidates[idx]; + } + + /** + * @param {Buffer} identity_fingerprint + * @return {Promise} + */ + get_onion_router_descriptor(identity_fingerprint) { + return this._download_from_random_router('/tor/server/fp/' + identity_fingerprint.toString('hex')); + } + + /** + * @param {string} path + * @param {boolean} only_authorities + * @return {Promise} + * @private + */ + async _download_from_random_router(path, only_authorities = false) { + let try_count = 0; + let result = ''; + + do { + result = await this._download_from_random_router_impl(path, only_authorities); + } while(++try_count < this._max_try_count && !result); + + return result; + } + + /** + * @param {string} path + * @param {boolean} only_authorities + * @return {Promise} + * @private + */ + _download_from_random_router_impl(path, only_authorities) { + let ip; + let port; + + // + // if the onion router map is empty, + // we're stuck to authorities anyway. + // + if (only_authorities || Object.keys(this._onion_router_map).length === 0) + { + const random_index = crypto.randomInt(authorities.length); + const authority = authorities[random_index]; + + ip = authority.ip; + port = authority.dir_port; + } + else + { + const router = this.get_random_onion_router_by_criteria({ + dir_ports: this._allowed_dir_ports, + flags: this._allowed_dir_flags + }); + + ip = router.ip; + port = router.dir_port; + } + + this._logger.debug(`consensus::download_from_random_authority() [path: http://${ip}:${port}${path}]`); + + return get('http:', ip, port, path); + } + + _parse_consensus(content) { + this._onion_router_map = {}; + this._valid_until = 0; + + const lines = content.split('\n'); + let current_router; + for (const line of lines) { + const parts = line.split(' '); + switch (parts[0]) { + case 'valid-until': + this._valid_until = new Date(`${parts[1]}T${parts[2]}Z`).getTime(); + break; + case 'r': + const identity_fingerprint = Buffer.from(parts[2], 'base64'); + const identity_fingerprint_hex = identity_fingerprint.toString('hex'); + const or_port = parseInt(parts[7], 10); + const dir_port = parseInt(parts[8], 10); + current_router = new OnionRouter(this, parts[1], parts[6], or_port, dir_port, identity_fingerprint); + this._onion_router_map[identity_fingerprint_hex] = current_router; + break; + case 's': + let flags = 0; + for (const part of parts) { + const idx = router_status_flags.indexOf(part); + if (idx === -1) continue; + flags |= 1 << idx; + } + current_router.flags = flags; + break; + case 'directory-footer': + return; + } + } + } +} + +module.exports = Consensus; diff --git a/src/tor/KeyAgreementNtor.js b/src/tor/KeyAgreementNtor.js new file mode 100644 index 0000000..01a0bbf --- /dev/null +++ b/src/tor/KeyAgreementNtor.js @@ -0,0 +1,131 @@ +const crypto = require('crypto'); +const hkdf = require('futoin-hkdf'); +const curve25519 = require('curve25519-js'); +const { hmac_sha256 } = require('../utils/crypto') + +const const_server = 'Server'; +const const_protoid = 'ntor-curve25519-sha256-1'; +const const_t_mac = 'ntor-curve25519-sha256-1:mac'; +const const_t_verify = 'ntor-curve25519-sha256-1:verify'; +const const_m_expand = 'ntor-curve25519-sha256-1:key_expand'; +const const_t_key = 'ntor-curve25519-sha256-1:key_extract'; + +class KeyAgreementNtor { + /** @type Buffer */ + _private_key = null; + /** @type Buffer */ + _public_key = null; + + constructor (or) { + this._onion_router = or; + const kp = curve25519.generateKeyPair(crypto.randomBytes(32)); + this._private_key = Buffer.from(kp.private); + this._public_key = Buffer.from(kp.public); + } + + get public_key() { + return this._public_key; + } + + compute_shared_secret(handshake_data) { + return this._compute_shared_secret(handshake_data.slice(0, 32), handshake_data.slice(32, 32 + 32)); + } + + _compute_shared_secret(other_public_key, verification_data) { + // + // 5.1.4. The "ntor" handshake + // + // In this section, define: + // H(x,t) as HMAC_SHA256 with message x and key t. + // H_LENGTH = 32. + // ID_LENGTH = 20. + // G_LENGTH = 32 + // PROTOID = "ntor-curve25519-sha256-1" + // t_mac = PROTOID | ":mac" + // t_key = PROTOID | ":key_extract" + // t_verify = PROTOID | ":verify" + // MULT(a,b) = the multiplication of the curve25519 point 'a' by the + // scalar 'b'. + // G = The preferred base point for curve25519 ([9]) + // KEYGEN() = The curve25519 key generation algorithm, returning + // a private/public keypair. + // m_expand = PROTOID | ":key_expand" + // KEYID(A) = A + // + // To perform the handshake, the client needs to know an identity key + // digest for the server, and an ntor onion key (a curve25519 public + // key) for that server. Call the ntor onion key "B". The client + // generates a temporary keypair: + // x,X = KEYGEN() + // and generates a client-side handshake with contents: + // NODEID Server identity digest [ID_LENGTH bytes] + // KEYID KEYID(B) [H_LENGTH bytes] + // CLIENT_PK X [G_LENGTH bytes] + // + // The server generates a keypair of y,Y = KEYGEN(), and uses its ntor + // private key 'b' to compute: + // + // secret_input = EXP(X,y) | EXP(X,b) | ID | B | X | Y | PROTOID + // KEY_SEED = H(secret_input, t_key) + // verify = H(secret_input, t_verify) + // auth_input = verify | ID | B | Y | X | PROTOID | "Server" + // + // The server's handshake reply is: + // SERVER_PK Y [G_LENGTH bytes] + // AUTH H(auth_input, t_mac) [H_LENGTH bytes] + // + // The client then checks Y is in G^* [see NOTE below], and computes + // + // secret_input = EXP(Y,x) | EXP(B,x) | ID | B | X | Y | PROTOID + // KEY_SEED = H(secret_input, t_key) + // verify = H(secret_input, t_verify) + // auth_input = verify | ID | B | Y | X | PROTOID | "Server" + // + // The client verifies that AUTH == H(auth_input, t_mac). + // + + //0d f8 4c b3 97 82 67 a5 b4 56 29 cc 21 c3 6f 0e 9f 83 48 3f 97 d4 45 bf 79 2b 51 7e e5 91 c8 2e + + const shared_key1 = Buffer.from(curve25519.sharedKey(this._private_key, other_public_key)); + const shared_key2 = Buffer.from(curve25519.sharedKey(this._private_key, this._onion_router.ntor_onion_key)); + + const secret_input = Buffer.concat([ + shared_key1, + shared_key2, + this._onion_router.identity_fingerprint, + this._onion_router.ntor_onion_key, + this._public_key, + other_public_key, + Buffer.from(const_protoid) + ], 32 + 32 + 20 + 32 + 32 + 32 + const_protoid.length); + + const verify = hmac_sha256(const_t_verify, secret_input); + + const auth_input = Buffer.concat([ + verify, + this._onion_router.identity_fingerprint, + this._onion_router.ntor_onion_key, + other_public_key, + this._public_key, + Buffer.from(const_protoid), + Buffer.from(const_server) + ], 32 + 20 + 32 + 32 + 32 + const_protoid.length + const_server.length); + + const computed_verification_data = hmac_sha256(const_t_mac, auth_input); + + if (verification_data.equals(computed_verification_data)) + { + // + // create key material. + // + return hkdf(secret_input, 92, { + salt: const_t_key, + info: const_m_expand, + hash: 'sha256' + }); + } + + return null; + } +} +module.exports = KeyAgreementNtor; diff --git a/src/tor/OnionRouter.js b/src/tor/OnionRouter.js new file mode 100644 index 0000000..c7b5432 --- /dev/null +++ b/src/tor/OnionRouter.js @@ -0,0 +1,64 @@ +class OnionRouter { + static none = 0x0000; + static authority = 0x0001; + static bad_exit = 0x0002; + static exit = 0x0004; + static fast = 0x0008; + static guard = 0x0010; + static hsdir = 0x0020; + static named = 0x0040; + static no_ed_consensus = 0x0080; + static stable = 0x0100; + static running = 0x0200; + static unnamed = 0x0400; + static valid = 0x0800; + static v2dir = 0x1000; + + /** @type Consensus */ + _consensus = null; + _nickname = ''; + _ip = ''; + _or_port = 0; + _dir_port = 0; + /** @type Buffer */ + _identity_fingerprint = null; + _descriptor_fetched = false; + _ntor_onion_key = null; + + constructor (consensus, nickname, ip, or_port, dir_port, identity_fingerprint) { + this._consensus = consensus; + this._nickname = nickname; + this._ip = ip; + this._or_port = or_port; + this._dir_port = dir_port; + this._identity_fingerprint = identity_fingerprint; + } + + get name() { return this._nickname }; + get ip() { return this._ip }; + get flags() { return this._flags }; + get or_port() { return this._or_port }; + get dir_port() { return this._dir_port }; + set flags(flags) { this._flags = flags; } + get ntor_onion_key() { + if (!this._descriptor_fetched) throw new Error('forgot to call fetch_descriptor for onion router'); + return this._ntor_onion_key; + } + get identity_fingerprint() { return this._identity_fingerprint; } + + async fetch_descriptor() { + const descriptor = await this._consensus.get_onion_router_descriptor(this._identity_fingerprint); + // parse + const lines = descriptor.split('\n'); + for (const line of lines){ + const parts = line.split(' '); + if (parts[0] === 'ntor-onion-key') { + this._ntor_onion_key = Buffer.from(parts[1], 'base64'); + break; + } + } + this._descriptor_fetched = true; + } +} + +module.exports = OnionRouter; diff --git a/src/tor/RelayCell.js b/src/tor/RelayCell.js new file mode 100644 index 0000000..5dce210 --- /dev/null +++ b/src/tor/RelayCell.js @@ -0,0 +1,25 @@ +const Cell = require('./Cell'); + +/** + * @extends {Cell} + */ +class RelayCell extends Cell { + constructor (circuit_id, cell_command, node, relay_command, stream_id, payload) { + super(circuit_id, cell_command, null); + this.node = node; + this.relay_command = relay_command; + this.stream_id = stream_id; + this.relay_payload = payload; + } + static fromCell(node, cell) { + const relay_command = cell.payload.readUInt8(0); + const recognized = cell.payload.readUInt16BE(1); + const stream_id = cell.payload.readUInt16BE(3); + const digest = cell.payload.readUInt32BE(5); + const payload_length = cell.payload.readUInt16BE(9); + const payload = cell.payload.slice(11, 11 + payload_length); + + return new RelayCell(cell.circuit_id, cell.command, node, relay_command, stream_id, payload); + } +} +module.exports = RelayCell; diff --git a/src/tor/Socket.js b/src/tor/Socket.js new file mode 100644 index 0000000..02e981d --- /dev/null +++ b/src/tor/Socket.js @@ -0,0 +1,280 @@ +const net = require('net'); +const tls = require('tls'); +const Cell = require('./Cell'); +const Circuit = require('./Circuit'); +const { defer } = require('../utils/time') +const { wait } = require('../utils/time') +const { time } = require('../utils/time') +const { parseIp } = require('../utils/net') + +const protocol_version_initial = 3; +const protocol_version_preferred = 4; + +const States = { + closed: 0, + connecting: 1, + handshake_in_progress: 2, + ready: 3 +} + +class Socket { + /** @type TLSSocket */ + _socket = null; + _onion_router = null; + _protocol_version = protocol_version_initial; + _circuit_map = {}; + _state = States.closed + _waits = {}; + // recv + _receive_payload_size = 0; + /** @type Cell */ + _cell = null; + + constructor (logger) { + this._logger = logger; + } + + get state() { + return this._state; + } + set state(state) { + this._state = state; + const waiters = this._waits[state]; + if (waiters) { + let d; + while(d = waiters.pop()) { + clearTimeout(d.timeout); + d.resolve(true); + } + } + } + + async connect(or) { + if (this.is_connected()) { + this.close(); + } + + this.state = States.connecting; + this._onion_router = or; + this._logger.debug("Connecting to", or.name, "@", or.ip, or.or_port); + this._socket = new tls.connect({ + host: or.ip, + port: or.or_port, + rejectUnauthorized: false + }); + await new Promise((resolve, reject) => { + this._socket.once('secureConnect', resolve); + this._socket.once('error', reject); + }); + this._socket.on('data', this._recv_cell.bind(this)); + + if (!this.is_connected()) { + this.state = States.closed; + return; + } + + this.state = States.handshake_in_progress; + + this._send_versions(); + + await this.wait_for_state(States.ready); + } + + async close() { + this._socket.end(); + this._socket.unref(); + this.state = States.closed; + } + + is_connected() { + return this._socket && !this._socket.connecting; + } + + async create_circuit(handshake = 'ntor') { + if (this.state !== States.ready) + { + return null; + } + + const new_circuit = new Circuit(this, this._logger); + this._circuit_map[new_circuit.circuit_id] = new_circuit; + await new_circuit.create(this._onion_router, handshake); + + // + // should't we close the socket in the case of failure? + // + return new_circuit.node_list.length === 1 + ? new_circuit + : null; + } + + remove_circuit(circuit) { + this._logger.debug("tor_socket::remove_circuit() [circuit: %i]", circuit.circuit_id & 0x7FFFFFFF); + + delete this._circuit_map[circuit.circuit_id]; + } + + async wait_for_state(desired_state, timeout = 30000) { + const d = defer(); + d.timeout = setTimeout(() => { + this._logger.debug("Socket.wait_for_state(): timed out"); + d.resolve(false); + }, timeout); + (this._waits[desired_state] = this._waits[desired_state] || []).push(d); + return d.promise; + } + + _recv_cell(chunk) { + for (let i = 0; i < chunk.length;) { + if (!this._cell) { + let circuit_id; + if (this._protocol_version < 4) { + circuit_id = chunk.readUInt16BE(i); + i += 2; + } else { + circuit_id = chunk.readInt32BE(i); + i += 4; + } + let command = chunk.readUInt8(i); + i++; + let payload_size = Cell.payload_size; + if (Cell.is_variable_length_cell_command(command)) { + payload_size = chunk.readUInt16BE(i); + i += 2; + } + this._cell = new Cell(circuit_id, command, Buffer.alloc(payload_size)); + this._receive_payload_left = payload_size; + } + const targetStart = this._cell.payload.length - this._receive_payload_left; + const length = Math.min(chunk.length - i, this._receive_payload_left); + const copied = chunk.copy(this._cell.payload, targetStart, i, i + length); + this._receive_payload_left -= copied; + i += copied; + + if (this._receive_payload_left === 0) { + const cell = this._cell; + this._cell = null; + this._handle_cell(cell); + } + } + } + + _handle_cell(cell) { + this._logger.debug("Socket._handle_cell(): %i %s", cell.circuit_id & 0x7fffffff, Cell.commands_lookup[cell.command], cell.payload); + if (cell.circuit_id) { + return this._circuit_map[cell.circuit_id].handle_cell(cell); + } + switch(cell.command) { + case Cell.commands.versions: + this._on_recv_versions(cell); + break; + case Cell.commands.certs: + //this._logger.debug("Socket recv certificates", cell); + break; + case Cell.commands.auth_challenge: + //this._logger.debug("Socket recv auth challenge", cell); + break; + case Cell.commands.netinfo: + this._on_recv_net_info(cell); + this._send_net_info(); + this.state = States.ready; + break; + default: + this._logger.debug("Cell unhandled"); + break; + } + } + + send_cell(cell) { + if (!this.is_connected()) return; + + this._logger.debug("Socket.send_cell(): %i %s", (cell.circuit_id & 0x7fffffff), Cell.commands_lookup[cell.command], cell.payload); + const bytes = cell.get_bytes(this._protocol_version); + this._socket.write(bytes); + } + + _send_versions() { + this._logger.debug("Socket._send_versions()"); + this.send_cell(new Cell(0, Cell.commands.versions, Buffer.from([0, 4]))); + } + + _on_recv_versions(versions_cell) { + this._logger.debug("Socket._recv_versions()"); + + for (let i = 0; i < versions_cell.payload.length; i += 2) + { + const offered_version = versions_cell.payload.readUInt16BE(i); + + if (offered_version === protocol_version_preferred) + { + this._protocol_version = offered_version; + this._logger.debug("switched to protocol v4"); + break; + } + } + } + + _on_recv_net_info(net_info_cell) { + this._logger.debug("Socket._recv_net_info()"); + //const cell = this._recv_cell(); + this._localAddress = "127.0.0.1"; + if (net_info_cell.payload.readUInt8(4) === 4 && net_info_cell.payload.readUInt8(5) === 4) { + const addr = net_info_cell.payload.readUInt32BE(6); + this._localAddress = [ + ((addr >> 24) & 0xff), + ((addr >> 16) & 0xff), + ((addr >> 8) & 0xff), + (addr & 0xff) + ].join('.') + this._logger.debug("local addr =", this._localAddress); + } + } + + _send_net_info() { + this._logger.debug("Socket._send_net_info()"); + + const remote = parseIp(this._socket.remoteAddress); + const local = parseIp(this._localAddress); + const epoch = time(); + + const net_info_bytes = Buffer.alloc(4 + 2 + 4 + 3 + 4); + + // + // If version 2 or higher is negotiated, each party sends the other a + // NETINFO Cell. The Cell's payload is: + // + // Timestamp [4 bytes] + // Other OR's address [variable] + // Number of addresses [1 byte] + // This OR's addresses [variable] + // + // Address is: + // Type (1 octet) + // Length (1 octet) + // Value (variable-width) + // + // "Type" is one of: + // 0x00 -- Hostname + // 0x04 -- IPv4 address + // 0x06 -- IPv6 address + // 0xF0 -- Error, transient + // 0xF1 -- Error, nontransient + // + + let n = net_info_bytes.writeUInt32BE(epoch, 0); + n = net_info_bytes.writeUInt8(4, n); // type + n = net_info_bytes.writeUInt8(4, n); // length + n = net_info_bytes.writeInt32BE(remote, n); + n = net_info_bytes.writeUInt8(1, n); // number of addresses + n = net_info_bytes.writeUInt8(4, n); // type + n = net_info_bytes.writeUInt8(4, n); // length + net_info_bytes.writeInt32BE(local, n); + + this.send_cell(new Cell( + 0, + Cell.commands.netinfo, + net_info_bytes)); + } +} + +module.exports = Socket; diff --git a/src/tor/TorStream.js b/src/tor/TorStream.js new file mode 100644 index 0000000..7fe28e0 --- /dev/null +++ b/src/tor/TorStream.js @@ -0,0 +1,124 @@ +const { Duplex } = require('stream'); + +const States = Object.freeze({ + none: 0, + connecting: 1, + ready: 2, + destroyed: 3, +}); + +class TorStream extends Duplex { + static states = States; + static window_start = 500; + static window_increment = 50; + static window_max_unflushed = 10; + + _stream_id = 0; + /** @type Circuit */ + _circuit = null; + _deliver_window = TorStream.window_start; + _package_window = TorStream.window_start; + /** @type Buffer */ + _buffer = null; + _state = States.connecting; + _waits = {}; + + /** + * @param {number} stream_id + * @param {Circuit} circuit + * @param logger + */ + constructor (stream_id, circuit, logger) { + super(); + this._stream_id = stream_id; + this._circuit = circuit; + this._logger = logger; + } + + get stream_id() { return this._stream_id; } + get state() { return this._state; } + set state(state) { + this._state = state; + if (state === States.destroyed) { + for (const k in this._waits) { + for (const wait of this._waits[k]) { + wait.resolve(false); + } + } + this._waits = {}; + } + } + + close() { + if (this.state === States.destroyed) { + return; + } + this._circuit.send_relay_end_cell(this); + } + + append_to_recv_buffer(data) { + if (this._canRead) { + this._canRead = this.push(data); + } else { + this._buffer = this._buffer + ? Buffer.concat([this._buffer, data], this._buffer.length + data.length) + : data; + } + } + + decrement_deliver_window() { + this._deliver_window--; + } + + consider_sending_sendme() { + if (this._deliver_window > (TorStream.window_start - TorStream.window_increment)) { + this._logger.debug("TorStream.consider_sending_sendme(): false"); + return false; + } + + // + // we're currently flushing immediatelly upon write, + // therefore there is no need to check unflushed cell count, + // because it's always 0. + // + // if (unflushed_cell_count >= window_max_unflushed) + // { + // return false; + // } + // + + this._deliver_window += TorStream.window_increment; + + this._logger.debug("TorStream.consider_sending_sendme(): true"); + return true; + } + + increment_package_window() { + this._deliver_window += TorStream.window_increment; + } + + get_stream() { + return this; + } + + _write(chunk, encoding, callback) { + let err = null; + try { + this._circuit.send_relay_data_cell(this, chunk); + } catch (error) { + err = error; + } finally { + callback(err); + } + } + + _read(size) { + if (this._buffer) { + this._canRead = this.push(this._buffer); + this._buffer = null; + } else { + this._canRead = true; + } + } +} +module.exports = TorStream; diff --git a/src/utils/crypto.js b/src/utils/crypto.js new file mode 100644 index 0000000..9823fa3 --- /dev/null +++ b/src/utils/crypto.js @@ -0,0 +1,9 @@ +const crypto = require('crypto'); +/** + * @returns {Buffer} + */ +function hmac_sha256(key, data) { + const h = crypto.createHmac('sha256', key); + return h.update(data).digest(); +} +exports.hmac_sha256 = hmac_sha256; diff --git a/src/utils/http.js b/src/utils/http.js new file mode 100644 index 0000000..10cd573 --- /dev/null +++ b/src/utils/http.js @@ -0,0 +1,35 @@ +const http = require('http'); +const https = require('https'); +const tls = require('tls'); + +/** + * @param {string} protocol + * @param {string} ip + * @param {string} port + * @param {string} path + * @param {object} [stream] + * @return {Promise} + */ +async function get(protocol, ip, port, path, stream) { + let options = {}; + if (stream) { + options.createConnection = function(args, cb) { + if (protocol === 'https:') { + return new tls.connect({socket: stream, servername: args.hostname}, () => cb()); + } + return stream; + } + } + + const proto = protocol === 'https:' ? https : http; + return new Promise((resolve, reject) => { + proto.get(`${protocol}//${ip}:${port}${path}`, options, res => { + let data = ''; + res.on('data', chunk => data += chunk.toString()); + res.on('end', () => resolve(data)); + res.on('error', (err) => reject(err)); + }); + }); +} + +exports.get = get; diff --git a/src/utils/net.js b/src/utils/net.js new file mode 100644 index 0000000..deb3069 --- /dev/null +++ b/src/utils/net.js @@ -0,0 +1,15 @@ +/** + * @param {string} ip + * @return {number} + */ +function parseIp(ip) { + const parts = ip.split('.').map(x => parseInt(x, 10)); + return ( + (parts[0] << 24) | + (parts[1] << 16) | + (parts[2] << 8) | + (parts[3]) + ) +} + +exports.parseIp = parseIp; diff --git a/src/utils/time.js b/src/utils/time.js new file mode 100644 index 0000000..7ef509c --- /dev/null +++ b/src/utils/time.js @@ -0,0 +1,19 @@ +function time() { + return Math.floor(Date.now()/1000); +} +exports.time = time; + +function wait(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} +exports.wait = wait; + +function defer() { + const d = {}; + d.promise = new Promise(function(resolve, reject) { + d.reject = reject; + d.resolve = resolve; + }); + return d; +} +exports.defer = defer;