diff --git a/package-lock.json b/package-lock.json index 754e048..282103d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2,6 +2,14 @@ "requires": true, "lockfileVersion": 1, "dependencies": { + "base32": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/base32/-/base32-0.0.6.tgz", + "integrity": "sha1-eQOLy1rsLY8ivMHChAKST1Cm0qw=", + "requires": { + "optimist": ">=0.1.0" + } + }, "curve25519-js": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz", @@ -12,10 +20,29 @@ "resolved": "https://registry.npmjs.org/futoin-hkdf/-/futoin-hkdf-1.3.2.tgz", "integrity": "sha512-3EVi3ETTyJg5PSXlxLCaUVVn0pSbDf62L3Gwxne7Uq+d8adOSNWQAad4gg7WToHkcgnCJb3Wlb1P8r4Evj4GPw==" }, + "minimist": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=" + }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + } + }, "tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + }, + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=" } } } diff --git a/package.json b/package.json index d7dee4d..5cf3693 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "base32": "0.0.6", "curve25519-js": "0.0.4", "futoin-hkdf": "^1.3.2", "tweetnacl": "^1.0.3" diff --git a/src/index.js b/src/index.js index de8c8ac..69137b4 100644 --- a/src/index.js +++ b/src/index.js @@ -1,8 +1,7 @@ const fs = require("fs"); const path = require("path"); const url = require("url"); -const http = require("http"); -const https = require("https"); +const util = require("util"); const Consensus = require("./tor/Consensus"); const OR = require("./tor/OnionRouter"); const Socket = require("./tor/Socket"); @@ -24,19 +23,23 @@ function timestamp() { const logger = { debug(...args) { if (LogLevels.debug > logLevel) return; - console.debug(timestamp(), "D", ...args); + const [format, ...rest] = args; + console.debug(timestamp(), "D", util.format(format, ...rest)); }, info(...args) { if (LogLevels.info > logLevel) return; - console.log(timestamp(), "I", ...args); + const [format, ...rest] = args; + console.debug(timestamp(), "I", util.format(format, ...rest)); }, warn(...args) { if (LogLevels.warn > logLevel) return; - console.warn(timestamp(), "W", ...args); + const [format, ...rest] = args; + console.debug(timestamp(), "W", util.format(format, ...rest)); }, error(...args) { if (LogLevels.error > logLevel) return; - console.error(timestamp(), "E", ...args); + const [format, ...rest] = args; + console.debug(timestamp(), "E", util.format(format, ...rest)); }, }; @@ -71,7 +74,8 @@ const logger = { /** @type Circuit */ let circuit; try { - const app_cache_path = path.join(process.env.HOME, '.cache', 'mini-tor-js'); + const home = process.env.HOME || process.env.USERPROFILE; + const app_cache_path = path.join(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 @@ -131,7 +135,13 @@ const logger = { } } /** @type TorStream */ - const tor_stream = await circuit.create_stream(uri.hostname, uri.port); + let tor_stream; + if (uri.hostname.endsWith('.onion')) { + const onion = uri.hostname.substr(0, uri.hostname.length - 6); + tor_stream = await circuit.create_onion_stream(onion, uri.port); + } else { + tor_stream = await circuit.create_stream(uri.hostname, uri.port); + } if (!tor_stream) { logger.error("Could not create stream to", uri.href); return; diff --git a/src/tor/Circuit.js b/src/tor/Circuit.js index 6f875a8..b6e223f 100644 --- a/src/tor/Circuit.js +++ b/src/tor/Circuit.js @@ -1,7 +1,9 @@ +const HiddenServiceConnector = require('./HiddenServiceConnector'); const CircuitNode = require('./CircuitNode'); const Cell = require('./Cell') const RelayCell = require('./RelayCell') const Stream = require('./TorStream') +const {sha1} = require('../utils/crypto') const { parseIp } = require('../utils/net') const { defer } = require('../utils/time') @@ -49,6 +51,10 @@ class Circuit { return this._node_list; } + get tor_socket() { + return this._socket; + } + get state() { return this._state }; set state(state) { @@ -417,7 +423,20 @@ class Circuit { } } - extend(or, handshake_type) { + async create_onion_stream(onion, port) { + const hidden_service_connector = new HiddenServiceConnector(this, onion); + + return await hidden_service_connector.connect() + ? this.create_stream(onion, port) + : null; + } + + create_dir_stream() { + //TODO + throw new Error('Not implemented'); + } + + extend(or, handshake_type = 'ntor') { switch (handshake_type) { case 'ntor': return this._extend_ntor(or); @@ -615,6 +634,132 @@ class Circuit { (this._waits[desired_state] = this._waits[desired_state] || []).push(d); return d.promise; } + + async rendezvous_establish(rendezvous_cookie) { + //TODO + throw new Error("Not implemented"); + } + + async rendezvous_introduce(rendezvous_circuit, rendezvous_cookie) { + //mini_assert(rendezvous_cookie.get_size() == 20); + + const introduction_point = this.get_final_circuit_node().onion_router; + const introducee = rendezvous_circuit.get_final_circuit_node.onion_router; + + this._logger.debug("circuit::rendezvous_introduce() [or: %s, state: introducing]", introduction_point.name); + this.state = States.rendezvous_introducing; + + this._logger.debug("circuit::rendezvous_introduce() [or: %s, state: completing]", introduction_point.name); + rendezvous_circuit.state = States.rendezvous_completing; + + // + // payload of the RELAY_COMMAND_INTRODUCE1 + // command: + // + // PK_ID Identifier for Bob's PK [20 octets] + // VER Version byte: set to 2. [1 octet] + // IP Rendezvous point's address [4 octets] + // PORT Rendezvous point's OR port [2 octets] + // ID Rendezvous point identity ID [20 octets] + // KLEN Length of onion key [2 octets] + // KEY Rendezvous point onion key [KLEN octets] + // RC Rendezvous cookie [20 octets] + // g^x Diffie-Hellman data, part 1 [128 octets] + // + + // + // compute PK_ID, aka hash of the service key. + // + const service_key_hash = sha1(introduction_point.service_key); + + // + // create rest of the payload in separate buffer; + // it will be encrypted. + // + const handshake_bytes = Buffer.alloc( + 1 + // version + 4 + // ip address + 2 + // port + 20 + // identity_fingerprint + 2 + // onion key size + 32 + // onion key + 20 + // rendezvous cookie + 128); // DH + + //io::memory_stream handshake_stream(handshake_bytes); + //io::stream_wrapper handshake_buffer(handshake_stream, endianness::big_endian); + + rendezvous_circuit._extend_node = new CircuitNode(this, introduction_point, circuit_node_type::introduction_point); + + handshake_buffer.write(static_cast(2)); + handshake_buffer.write(swap_endianness(introducee->get_ip_address().to_int())); + handshake_buffer.write(introducee->get_or_port()); + handshake_buffer.write(introducee->get_identity_fingerprint()); + handshake_buffer.write(static_cast(introducee->get_onion_key().get_size())); + handshake_buffer.write(introducee->get_onion_key()); + handshake_buffer.write(rendezvous_cookie); + handshake_buffer.write(rendezvous_circuit->_extend_node->get_key_agreement().get_public_key()); + + const handshake_encrypted = hybrid_encryption::encrypt( + handshake_bytes, + introduction_point.service_key); + + // + // compose the final payload. + // + const relay_payload_bytes = Buffer.concat([ + service_key_hash, + handshake_encrypted + ], service_key_hash.length + handshake_encrypted.length); + + // + // send the cell. + // + this.send_relay_cell( + 0, + Cell.commands.relay_command_introduce1, + relay_payload_bytes); + + if (await this.wait_for_state(States.rendezvous_introduced)) + { + this._logger.debug("circuit::rendezvous_introduce() [or: %s, state: introduced]", introduction_point.name); + } + else + { + this._logger.error("circuit::rendezvous_introduce() [or: %s, is_rendezvous_introduced() == false]", introduction_point.name); + + // + // we cannot expect the rendezvous will be completed. + // + + return; + } + + if (await rendezvous_circuit.wait_for_state(States.rendezvous_completed)) + { + this._logger.debug("circuit::rendezvous_introduce() [or: %s, state: completed]", introduction_point.name); + } + else + { + this._logger.error("circuit::rendezvous_introduce() [or: %s, is_rendezvous_completed() == false]", introduction_point.name); + } + } + + is_ready() { + return this.state === States.ready; + } + + is_rendezvous_established() { + return this.state === States.rendezvous_established; + } + + is_rendezvous_completed() { + return this.state === States.rendezvous_completed; + } + + is_rendezvous_introduced() { + return this.state === States.rendezvous_introduced; + } } module.exports = Circuit; diff --git a/src/tor/Consensus.js b/src/tor/Consensus.js index b29be05..b8d22fc 100644 --- a/src/tor/Consensus.js +++ b/src/tor/Consensus.js @@ -18,7 +18,7 @@ const authorities = [ 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("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), @@ -197,7 +197,7 @@ class Consensus { port = router.dir_port; } - this._logger.debug(`consensus::download_from_random_authority() [path: http://${ip}:${port}${path}]`); + this._logger.debug(`Consensus._download_from_random_router_impl() [path: http://${ip}:${port}${path}]`); return get('http:', ip, port, path); } @@ -236,6 +236,14 @@ class Consensus { } } } + + /** + * @param {Buffer} identity_fingerprint + * @returns {OnionRouter} + */ + get_onion_router_by_identity_fingerprint(identity_fingerprint) { + return this._onion_router_map[identity_fingerprint.toString('hex')]; + } } module.exports = Consensus; diff --git a/src/tor/HiddenServiceConnector.js b/src/tor/HiddenServiceConnector.js new file mode 100644 index 0000000..2731104 --- /dev/null +++ b/src/tor/HiddenServiceConnector.js @@ -0,0 +1,346 @@ +const crypto = require('crypto'); +const base32 = require('base32') +const OnionRouter = require('./OnionRouter') +const {get} = require('../utils/http') +const {time} = require('../utils/time') +const {sha1} = require('../utils/crypto') + +class HiddenServiceConnector { + /** @type Consensus */ + _consensus = null; + /** @type OnionRouter[] */ + _introduction_point_list = []; + /** @type OnionRouter[] */ + _responsible_directory_list = []; + /** @type Buffer */ + _rendezvous_cookie = null; + + constructor (rendezvous_circuit, onion) { + this._rendezvous_circuit = rendezvous_circuit; + this._socket = rendezvous_circuit.tor_socket; + this._consensus = rendezvous_circuit.tor_socket.onion_router.consensus; + this._onion = onion; + this._permanent_id = Buffer.from(base32.decode(onion)); + } + + async connect() { + this.find_responsible_directories(); + + if (this._responsible_directory_list.length) + { + // + // create rendezvous cookie. + // + this._rendezvous_cookie = crypto.randomBytes(20); + + // + // establish rendezvous. + // + await this._rendezvous_circuit.rendezvous_establish(this._rendezvous_cookie); + + if (this._rendezvous_circuit.is_rendezvous_established()) + { + let responsible_directory_index = 0; + while ((responsible_directory_index = await this.fetch_hidden_service_descriptor(responsible_directory_index)) !== -1) + { + await this.introduce(); + + if (this._rendezvous_circuit.is_rendezvous_completed()) + { + return true; + } + } + } + } + + return false; + } + + find_responsible_directories() { + // + // rend-spec.txt + // 1.4. + // At any time, there are 6 hidden service directories responsible for + // keeping replicas of a descriptor; they consist of 2 sets of 3 hidden + // service directories with consecutive onion IDs. Bob's OP learns about + // the complete list of hidden service directories by filtering the + // consensus status document received from the directory authorities. A + // hidden service directory is deemed responsible for a descriptor ID if + // it has the HSDir flag and its identity digest is one of the first three + // identity digests of HSDir relays following the descriptor ID in a + // circular list. A hidden service directory will only accept a descriptor + // whose timestamp is no more than three days before or one day after the + // current time according to the directory's clock. + // + + this._responsible_directory_list = []; + + const directory_list = this._consensus.get_onion_routers_by_criteria({ + flags: OnionRouter.hsdir + }); + + // + // search for the 2 sets of 3 hidden service directories. + // + for (let replica = 0; replica < 2; replica++) + { + const descriptor_id = this.get_descriptor_id(replica); + + const index = directory_list.findIndex(x => Buffer.compare(x.identity_fingerprint, descriptor_id) < 0); + + for (let i = 0; i < 3; i++) + { + this._responsible_directory_list.push(directory_list[(index + i) % directory_list.length]); + } + } + } + + get_secret_id(replica) { + const permanent_id_byte = this._permanent_id[0]; + + // + // rend-spec.txt + // 1.3. + // + // "time-period" changes periodically as a function of time and + // "permanent-id". The current value for "time-period" can be calculated + // using the following formula: + // + // time-period = (current-time + permanent-id-byte * 86400 / 256) + // / 86400 + // + const time_period = (time() + (permanent_id_byte * 86400 / 256)) / 86400; + + const secret_bytes = Buffer.alloc(5); + secret_bytes.writeInt32BE(time_period); + secret_bytes.writeUInt8(replica); + + return sha1(secret_bytes); + } + + /** + * @param replica + * @returns {Buffer} + */ + get_descriptor_id(replica) { + const secret_id = this.get_secret_id(replica); + + const descriptor_id_bytes = Buffer.concat([ + this._permanent_id, + secret_id + ], this._permanent_id.length + secret_id.length); + + return sha1(descriptor_id_bytes); + } + + async fetch_hidden_service_descriptor(responsible_directory_index) { + for (let i = responsible_directory_index; i < this._responsible_directory_list.length; i++) + { + const responsible_directory = this._responsible_directory_list[i]; + + // + // create new circuit and extend it with responsible directory. + // + this._logger.info( + "\tCreating circuit for hidden service (try #%i), connecting to '%s' (%s:%i)", + i + 1, + this._socket.onion_router.name, + this._socket.onion_router.ip, + this._socket.onion_router.or_port); + this._logger.info("\tConnected..."); + + /** @type Circuit */ + const directory_circuit = await this._socket.create_circuit(); + + if (!directory_circuit) + { + // + // either tor socket is destroyed + // or we couldn't create circuit with the first + // onion router. try it again anyway. + // but if the socket is destroyed, we're out of luck. + // + continue; + } + + this._logger.info( + "\tExtending circuit for hidden service, connecting to responsible directory '%s' (%s:%u)", + responsible_directory.name, + responsible_directory.ip, + responsible_directory.or_port); + + await directory_circuit.extend(responsible_directory); + + if (!directory_circuit.is_ready()) + { + this._logger.warn("\tError while extending the directory circuit"); + continue; + } + + // + // circuit must have exactly 2 nodes now. + // + //mini_assert(directory_circuit->get_circuit_node_list_size() == 2); + + this._logger.info("\tExtended..."); + + const replica = i >= 3; + + // + // create the directory stream on the directory circuit. + // + const directory_stream = directory_circuit.create_dir_stream(); + + if (!directory_stream) + { + this._logger.warn("\tError while establishing the directory stream"); + continue; + } + + // + // request the hidden service descriptor. + // + const descriptor_path = "/tor/rendezvous2/" + base32.encode(this.get_descriptor_id(replica)); + + this._logger.debug( + "hidden_service::fetch_hidden_service_descriptor() [path: %s]", + descriptor_path); + + this._logger.info("\tSending request for hidden service descriptor..."); + + const hidden_service_descriptor = await get( + 'http:', + responsible_directory.ip, + responsible_directory.dir_port.toString(), + descriptor_path, + directory_stream); + + this._logger.info("\tHidden service descriptor received..."); + + // + // parse hidden service descriptor. + // + if (!hidden_service_descriptor && + !hidden_service_descriptor.includes("404 Not found")) + { + this._logger.info("\tHidden service descriptor is valid..."); + + let lines = hidden_service_descriptor.split('\n'); + let desc = '', capture = false; + for (const line of lines) { + if (line === '-----BEGIN MESSAGE-----') { + capture = true; + } else if (line === '-----END MESSAGE-----') { + capture = false; + break; + } else if (capture) { + desc += line; + } + } + const introduction_point_list = []; + const introduction_point_desc = Buffer.from(desc, 'base64').toString(); + lines = introduction_point_desc.split('\n'); + let current_router, serviceKey = null; + for (const line of lines) { + const parts = line.split(' '); + if (parts[0] === 'introduction-point') { + const identity_fingerprint = Buffer.from(base32.decode(parts[1])); + current_router = this._consensus.get_onion_router_by_identity_fingerprint(identity_fingerprint); + } else if (parts[0] === 'service-key') { + serviceKey = ''; + } else if (parts[0] === '-----BEGIN RSA PUBLIC KEY-----' && serviceKey !== null) { + capture = true; + } else if (parts[0] === '-----END RSA PUBLIC KEY-----' && serviceKey !== null) { + current_router.service_key = serviceKey; + introduction_point_list.push(current_router); + capture = false; + serviceKey = null; + } else if (capture) { + serviceKey += line; + } + } + + //mini_assert(!parser.introduction_point_list.is_empty()); + + if (introduction_point_list.length) + { + this._introduction_point_list = introduction_point_list + } + else + { + this._logger.warn("\tHidden service descriptor contains no introduction points..."); + } + + return i; + } + else + { + this._logger.warn("\tHidden service descriptor is invalid..."); + } + } + + return -1; + } + + async introduce() { + for (const introduction_point of this._introduction_point_list) + { + this._logger.info( + "\tCreating circuit for hidden service introduce, connecting to '%s' (%s:%i)", + this._socket.onion_router.name, + this._socket.onion_router.ip, + this._socket.onion_router.or_port); + + /** @type Circuit */ + const introduce_circuit = await this._socket.create_circuit(); + + if (!introduce_circuit) + { + // + // either tor socket is destroyed + // or we couldn't create circuit with the first + // onion router. try it again anyway. + // but if the socket is destroyed, we're out of luck. + // + continue; + } + + this._logger.info("\tConnected..."); + + this._logger.info( + "\tExtending circuit to introduction point '%s' (%s:%u)", + introduction_point.name, + introduction_point.ip, + introduction_point.or_port); + + await introduce_circuit.extend(introduction_point); + + if (!introduce_circuit.is_ready()) + { + this._logger.warn("\tError while extending the introduce circuit"); + continue; + } + + // + // circuit must have exactly 2 nodes now. + // + //mini_assert(introduce_circuit->get_circuit_node_list_size() == 2); + + this._logger.info("\tExtended..."); + this._logger.info("\tSending introduce..."); + + await introduce_circuit.rendezvous_introduce(this._rendezvous_circuit, this._rendezvous_cookie); + + if (introduce_circuit.is_rendezvous_introduced()) + { + this._logger.info("\tIntroduced successfully..."); + break; + } + else + { + this._logger.warn("\tIntroduce failed..."); + } + } + } +} +module.exports = HiddenServiceConnector; diff --git a/src/tor/OnionRouter.js b/src/tor/OnionRouter.js index c7b5432..a0bf8e4 100644 --- a/src/tor/OnionRouter.js +++ b/src/tor/OnionRouter.js @@ -24,6 +24,7 @@ class OnionRouter { _identity_fingerprint = null; _descriptor_fetched = false; _ntor_onion_key = null; + _service_key = null; constructor (consensus, nickname, ip, or_port, dir_port, identity_fingerprint) { this._consensus = consensus; @@ -34,17 +35,20 @@ class OnionRouter { 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 }; + get consensus() { return this._consensus; } + 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; } + set service_key(key) { this._service_key = key; } + get service_key() { return this._service_key; } async fetch_descriptor() { const descriptor = await this._consensus.get_onion_router_descriptor(this._identity_fingerprint); diff --git a/src/tor/Socket.js b/src/tor/Socket.js index 02e981d..4785242 100644 --- a/src/tor/Socket.js +++ b/src/tor/Socket.js @@ -34,6 +34,9 @@ class Socket { this._logger = logger; } + get onion_router() { + return this._onion_router; + } get state() { return this._state; } @@ -90,6 +93,10 @@ class Socket { return this._socket && !this._socket.connecting; } + /** + * @param handshake + * @returns {Promise} + */ async create_circuit(handshake = 'ntor') { if (this.state !== States.ready) { diff --git a/src/utils/crypto.js b/src/utils/crypto.js index 9823fa3..531538a 100644 --- a/src/utils/crypto.js +++ b/src/utils/crypto.js @@ -7,3 +7,13 @@ function hmac_sha256(key, data) { return h.update(data).digest(); } exports.hmac_sha256 = hmac_sha256; + +/** + * @param {BinaryLike} data + * @returns {Buffer} + */ +function sha1(data) { + const h = crypto.createHash('sha1'); + return h.update(data).digest(); +} +exports.sha1 = sha1;