initial commit
This commit is contained in:
commit
a5230b9105
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules
|
||||||
|
.idea
|
21
package-lock.json
generated
Normal file
21
package-lock.json
generated
Normal file
|
@ -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=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
7
package.json
Normal file
7
package.json
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"curve25519-js": "0.0.4",
|
||||||
|
"futoin-hkdf": "^1.3.2",
|
||||||
|
"tweetnacl": "^1.0.3"
|
||||||
|
}
|
||||||
|
}
|
148
src/index.js
Normal file
148
src/index.js
Normal file
|
@ -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));
|
172
src/tor/Cell.js
Normal file
172
src/tor/Cell.js
Normal file
|
@ -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<circuit_id_v3_type>(static_cast<circuit_id_v3_type>(_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;
|
620
src/tor/Circuit.js
Normal file
620
src/tor/Circuit.js
Normal file
|
@ -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;
|
84
src/tor/CircuitNode.js
Normal file
84
src/tor/CircuitNode.js
Normal file
|
@ -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 <20> 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;
|
100
src/tor/CircuitNodeCryptoState.js
Normal file
100
src/tor/CircuitNodeCryptoState.js
Normal file
|
@ -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;
|
241
src/tor/Consensus.js
Normal file
241
src/tor/Consensus.js
Normal file
|
@ -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<string>}
|
||||||
|
*/
|
||||||
|
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<string>}
|
||||||
|
* @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<string>}
|
||||||
|
* @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;
|
131
src/tor/KeyAgreementNtor.js
Normal file
131
src/tor/KeyAgreementNtor.js
Normal file
|
@ -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;
|
64
src/tor/OnionRouter.js
Normal file
64
src/tor/OnionRouter.js
Normal file
|
@ -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;
|
25
src/tor/RelayCell.js
Normal file
25
src/tor/RelayCell.js
Normal file
|
@ -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;
|
280
src/tor/Socket.js
Normal file
280
src/tor/Socket.js
Normal file
|
@ -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;
|
124
src/tor/TorStream.js
Normal file
124
src/tor/TorStream.js
Normal file
|
@ -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;
|
9
src/utils/crypto.js
Normal file
9
src/utils/crypto.js
Normal file
|
@ -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;
|
35
src/utils/http.js
Normal file
35
src/utils/http.js
Normal file
|
@ -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<string>}
|
||||||
|
*/
|
||||||
|
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;
|
15
src/utils/net.js
Normal file
15
src/utils/net.js
Normal file
|
@ -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;
|
19
src/utils/time.js
Normal file
19
src/utils/time.js
Normal file
|
@ -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;
|
Loading…
Reference in New Issue
Block a user