Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tor external process - Allows Trezor Suite use Tor from Tails OS #15642

Open
wants to merge 16 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/node-utils/src/checkSocks5Proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import net from 'net';

export const checkSocks5Proxy = (host: string, port: number): Promise<boolean> => {
return new Promise((resolve, reject) => {
const socket = new net.Socket();

socket.setTimeout(2_000);

socket.on('connect', () => {
// Version 5, 1 method, no authentication
const handshakeRequest = Buffer.from([0x05, 0x01, 0x00]);
socket.write(handshakeRequest);
});

socket.on('data', data => {
if (data[0] === 0x05 && data[1] === 0x00) {
resolve(true);
} else {
resolve(false);
}
socket.destroy();
});

socket.on('error', err => {
reject(err);
});

socket.on('timeout', () => {
socket.destroy();
reject(new Error('Connection timed out'));
});

socket.connect(port, host);
});
};
1 change: 1 addition & 0 deletions packages/node-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export {
type Response,
} from './http';
export { checkFileExists } from './checkFileExists';
export { checkSocks5Proxy } from './checkSocks5Proxy';
62 changes: 62 additions & 0 deletions packages/node-utils/src/tests/checkSocks5Proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import net from 'net';

import { checkSocks5Proxy } from '../checkSocks5Proxy';

jest.mock('net');

describe('checkSocks5Proxy', () => {
const host = '127.0.0.1';
const port = 9050;

afterEach(() => {
jest.clearAllMocks();
});

it('should return true for a valid SOCKS5 proxy', async () => {
const mockSocket = {
connect: jest.fn(),
write: jest.fn(),
on: jest.fn((event, callback) => {
if (event === 'connect') {
callback();
}
if (event === 'data') {
// Valid SOCKS5 response.
callback(Buffer.from([0x05, 0x00]));
}
}),
setTimeout: jest.fn(),
destroy: jest.fn(),
};

// @ts-expect-error
net.Socket.mockImplementation(() => mockSocket);

const result = await checkSocks5Proxy(host, port);
expect(result).toBe(true);
});

it('should return false for an invalid SOCKS5 proxy', async () => {
const mockSocket = {
connect: jest.fn(),
write: jest.fn(),
on: jest.fn((event, callback) => {
if (event === 'connect') {
callback();
}
if (event === 'data') {
// Not valid SOCKS5 response
callback(Buffer.from([0x05, 0x01]));
}
}),
setTimeout: jest.fn(),
destroy: jest.fn(),
};

// @ts-expect-error
net.Socket.mockImplementation(() => mockSocket);

const result = await checkSocks5Proxy(host, port);
expect(result).toBe(false);
});
});
1 change: 0 additions & 1 deletion packages/request-manager/e2e/identities-stress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ const intervalBetweenRequests = 1000 * 20;
port,
controlPort,
torDataDir,
snowflakeBinaryPath: '',
});
const torParams = await torController.getTorConfiguration(processId);
marekrjpolak marked this conversation as resolved.
Show resolved Hide resolved
// Starting Tor process from binary.
Expand Down
4 changes: 1 addition & 3 deletions packages/request-manager/e2e/interceptor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ const hostIp = '127.0.0.1';
const port = 38835;
const controlPort = 35527;
const processId = process.pid;
const snowflakeBinaryPath = '';

// 1 minute before timeout, because Tor might be slow to start.
jest.setTimeout(60000);
Expand All @@ -29,7 +28,7 @@ describe('Interceptor', () => {
let torController: TorController;
let torIdentities: TorIdentities;

const torSettings = { running: true, host: hostIp, port, snowflakeBinaryPath };
const torSettings = { running: true, host: hostIp, port };

const INTERCEPTOR = {
handler: () => {},
Expand All @@ -45,7 +44,6 @@ describe('Interceptor', () => {
port,
controlPort,
torDataDir,
snowflakeBinaryPath,
});
const torParams = await torController.getTorConfiguration(processId);
marekrjpolak marked this conversation as resolved.
Show resolved Hide resolved
// Starting Tor process from binary.
Expand Down
3 changes: 0 additions & 3 deletions packages/request-manager/e2e/torControlPort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ const controlAuthCookiePath = `${torDataDir}/control_auth_cookie`;
const host = 'localhost';
const port = 9998;
const controlPort = 9999;
const snowflakeBinaryPath = '';

describe('TorControlPort', () => {
beforeAll(async () => {
Expand All @@ -40,7 +39,6 @@ describe('TorControlPort', () => {
port,
controlPort,
torDataDir,
snowflakeBinaryPath,
};
const fakeListener = () => {};
const torControlPort = new TorControlPort(options, fakeListener);
Expand Down Expand Up @@ -105,7 +103,6 @@ describe('TorControlPort', () => {
port,
controlPort,
torDataDir,
snowflakeBinaryPath,
};
const fakeListener = () => {};
const torControlPort = new TorControlPort(options, fakeListener);
Expand Down
147 changes: 53 additions & 94 deletions packages/request-manager/src/controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { EventEmitter } from 'events';
import path from 'path';

import { createTimeoutPromise } from '@trezor/utils';
import { checkFileExists } from '@trezor/node-utils';
import { ScheduleActionParams, ScheduledAction, scheduleAction } from '@trezor/utils';

import { TorControlPort } from './torControlPort';
import {
Expand All @@ -13,15 +12,15 @@ import {
} from './types';
import { bootstrapParser, BOOTSTRAP_EVENT_PROGRESS } from './events/bootstrap';

const WAITING_TIME = 1000;
const MAX_TRIES_WAITING = 200;
const BOOTSTRAP_SLOW_TRESHOLD = 1000 * 5; // 5 seconds.

export class TorController extends EventEmitter {
options: TorConnectionOptions;
controlPort: TorControlPort;
bootstrapSlownessChecker?: NodeJS.Timeout;
status: TorControllerStatus = TOR_CONTROLLER_STATUS.Stopped;
// Configurations
waitingTime = 1000;
maxTriesWaiting = 200;
bootstrapSlowThreshold = 1000 * 5; // 5 seconds.

constructor(options: TorConnectionOptions) {
super();
Expand Down Expand Up @@ -57,23 +56,37 @@ export class TorController extends EventEmitter {
if (this.bootstrapSlownessChecker) {
clearTimeout(this.bootstrapSlownessChecker);
}
// When Bootstrap starts we wait time defined in bootstrapSlowThreshold and if after that time,
// When Bootstrap starts we wait time defined in BOOTSTRAP_SLOW_TRESHOLD and if after that time,
// it has not being finalized, then we send slow event. We know that Bootstrap is going on since
// we received, at least, first Bootstrap events from ControlPort.
this.bootstrapSlownessChecker = setTimeout(() => {
this.emit('bootstrap/event', {
type: 'slow',
});
}, this.bootstrapSlowThreshold);
}, BOOTSTRAP_SLOW_TRESHOLD);
}

public async getTorConfiguration(
processId: number,
snowflakeBinaryPath?: string,
): Promise<string[]> {
private onMessageReceived(message: string) {
const bootstrap: BootstrapEvent[] = bootstrapParser(message);
bootstrap.forEach(event => {
if (event.type !== 'progress') return;
if (event.progress && !this.getIsBootstrapping()) {
// We consider that bootstrap has started when we receive any bootstrap event and
// Tor is not bootstrapping yet.
// If we do not receive any bootstrapping event, we can consider there is something going wrong and
// an error will be thrown when `MAX_TRIES_WAITING` is reached in `waitUntilAlive`.
this.startBootstrap();
}
if (event.progress === BOOTSTRAP_EVENT_PROGRESS.Done) {
this.successfullyBootstrapped();
}
this.emit('bootstrap/event', event);
});
}

public getTorConfiguration(processId: number): string[] {
const { torDataDir } = this.options;
const controlAuthCookiePath = path.join(torDataDir, 'control_auth_cookie');
const snowflakeLogPath = path.join(torDataDir, 'snowflake.log');

// https://github.com/torproject/tor/blob/bf30943cb75911d70367106af644d4273baaa85d/doc/man/tor.1.txt
const config: string[] = [
Expand Down Expand Up @@ -136,96 +149,42 @@ export class TorController extends EventEmitter {
this.options.torDataDir,
];

let existsSnowflakeBinary = false;
if (snowflakeBinaryPath && snowflakeBinaryPath.trim() !== '') {
// If provided snowflake file does not exists, do not use it.
existsSnowflakeBinary = await checkFileExists(snowflakeBinaryPath);
}

if (existsSnowflakeBinary) {
// Snowflake is a WebRTC pluggable transport for Tor (client)
// More info:
// https://gitlab.torproject.org/tpo/anti-censorship/pluggable-transports/snowflake/-/tree/main/client
// https://packages.debian.org/bookworm/snowflake-client

const SNOWFLAKE_PLUGIN = 'snowflake exec';
const SNOWFLAKE_SERVER = 'snowflake 192.0.2.3:80';
const SNOWFLAKE_FINGERPRINT = '2B280B23E1107BB62ABFC40DDCC8824814F80A72';
const SNOWFLAKE_URL = 'https://snowflake-broker.torproject.net.global.prod.fastly.net/';
const SNOWFLAKE_FRONT = 'fronts=foursquare.com,github.githubassets.com';
const SNOWFLAKE_ICE =
'ice=stun:stun.l.google.com:19302,stun:stun.antisip.com:3478,stun:stun.bluesip.net:3478,stun:stun.dus.net:3478,stun:stun.epygi.com:3478,stun:stun.sonetel.com:3478,stun:stun.uls.co.za:3478,stun:stun.voipgate.com:3478,stun:stun.voys.nl:3478';
const SNOWFLAKE_UTLS = 'utls-imitate=hellorandomizedalpn';

const snowflakeCommand = `${SNOWFLAKE_PLUGIN} ${snowflakeBinaryPath} -log ${snowflakeLogPath}`;
const snowflakeBridge = `${SNOWFLAKE_SERVER} ${SNOWFLAKE_FINGERPRINT} fingerprint=${SNOWFLAKE_FINGERPRINT} url=${SNOWFLAKE_URL} ${SNOWFLAKE_FRONT} ${SNOWFLAKE_ICE} ${SNOWFLAKE_UTLS}`;

config.push(
'--UseBridges',
'1',
'--ClientTransportPlugin',
snowflakeCommand,
'--Bridge',
snowflakeBridge,
);
}

return config;
}

public onMessageReceived(message: string) {
const bootstrap: BootstrapEvent[] = bootstrapParser(message);
bootstrap.forEach(event => {
if (event.type !== 'progress') return;
if (event.progress && !this.getIsBootstrapping()) {
// We consider that bootstrap has started when we receive any bootstrap event and
// Tor is not bootstrapping yet.
// If we do not receive any bootstrapping event, we can consider there is something going wrong and
// an error will be thrown when `maxTriesWaiting` is reached in `waitUntilAlive`.
this.startBootstrap();
}
if (event.progress === BOOTSTRAP_EVENT_PROGRESS.Done) {
this.successfullyBootstrapped();
}
this.emit('bootstrap/event', event);
});
}

public waitUntilAlive(): Promise<void> {
const errorMessages: string[] = [];
public async waitUntilAlive(): Promise<void> {
this.status = TOR_CONTROLLER_STATUS.Bootstrapping;
const waitUntilResponse = async (triesCount: number): Promise<void> => {
if (this.getIsStopped()) {
// If TOR is starting and we want to cancel it.
return;

const abortController = new AbortController();
const checkConnection: ScheduledAction<boolean> = async signal => {
if (signal?.aborted) {
throw new Error('Tor controller check alive aborted');
}
if (triesCount >= this.maxTriesWaiting) {
throw new Error(
`Timeout waiting for TOR control port: \n${errorMessages.join('\n')}`,
);
const isConnected = await this.controlPort.connect();
const isAlive = this.controlPort.ping();
const isCircuitEstablished = this.getIsCircuitEstablished();
// It is running so let's not wait anymore.
if (isConnected && isAlive && isCircuitEstablished) {
return true;
}
try {
const isConnected = await this.controlPort.connect();
const isAlive = this.controlPort.ping();
if (isConnected && isAlive && this.getIsCircuitEstablished()) {
// It is running so let's not wait anymore.
return;
}
} catch (error) {
// Some error here is expected when waiting but
// we do not want to throw until maxTriesWaiting is reach.
// Instead we want to log it to know what causes the error.
if (error && error.message) {
console.warn('request-manager:', error.message);
errorMessages.push(error.message);
throw new Error('Tor not alive');
};
const params: ScheduleActionParams = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is much better, in my opinion. Just two things:

  • timeout is a limit for one action attempt after which it throws instead, while gap is a delay between two consecutive attempts. It's perfectly fine to have both defined, I just want to make sure that it's what you want.
  • I think there's no need for abortController check inside an action and aborting it from attemptFailureHandler, as the getIsStopped check could be done in the action itself, which makes attemptFailureHandler redundant.. If you're not against, I can try to propose an adjustment after the review.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this work for you?

        const checkConnection: ScheduledAction<boolean> = async () => {
            if (this.getIsStopped()) return false;
            const isConnected = await this.controlPort.connect();
            const isAlive = this.controlPort.ping();
            const isCircuitEstablished = this.getIsCircuitEstablished();
            // It is running so let's not wait anymore.
            if (isConnected && isAlive && isCircuitEstablished) {
                return true;
            }
            throw new Error('Tor not alive');
        };
        const params: ScheduleActionParams = {
            attempts: MAX_TRIES_WAITING,
            timeout: WAITING_TIME,
            gap: WAITING_TIME,
        };

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understood the difference between timeout and gap, and think in this case both are necessary.

Regarding abortController if you want to propose and adjustment, it is welcome :)

attempts: MAX_TRIES_WAITING,
timeout: WAITING_TIME,
gap: WAITING_TIME,
signal: abortController.signal,
attemptFailureHandler: () => {
if (this.getIsStopped()) {
abortController.abort();

return new Error('Operation stopped.');
}
}
await createTimeoutPromise(this.waitingTime);

return waitUntilResponse(triesCount + 1);
return undefined;
},
};

return waitUntilResponse(1);
await scheduleAction(checkConnection, params);
}

public getStatus(): Promise<TorControllerStatus> {
Expand Down
Loading
Loading