tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 844d14a86646f2358fccdb211be6b4b8d82ff7ad
parent dbdbacab52305ddd875b1773e868e8306623b42a
Author: Pier Angelo Vendrame <pierov@torproject.org>
Date:   Mon, 10 Oct 2022 15:13:04 +0200

TB 40933: Add tor-launcher functionality

Bug 41926: Reimplement the control port

Diffstat:
Mbrowser/app/profile/000-tor-browser.js | 2++
Mbrowser/installer/package-manifest.in | 3+++
Mtoolkit/components/moz.build | 1+
Atoolkit/components/tor-launcher/TorBootstrapRequest.sys.mjs | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/tor-launcher/TorControlPort.sys.mjs | 1348+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/tor-launcher/TorLauncherUtil.sys.mjs | 702+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/tor-launcher/TorParsers.sys.mjs | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/tor-launcher/TorProcess.sys.mjs | 393+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/tor-launcher/TorProcessAndroid.sys.mjs | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/tor-launcher/TorProvider.sys.mjs | 1148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/tor-launcher/TorProviderBuilder.sys.mjs | 338+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/tor-launcher/TorStartupService.sys.mjs | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/components/tor-launcher/components.conf | 10++++++++++
Atoolkit/components/tor-launcher/moz.build | 19+++++++++++++++++++
Atoolkit/components/tor-launcher/tor-launcher.manifest | 2++
15 files changed, 4414 insertions(+), 0 deletions(-)

diff --git a/browser/app/profile/000-tor-browser.js b/browser/app/profile/000-tor-browser.js @@ -131,3 +131,5 @@ pref("extensions.torlauncher.bridgedb_reflector", "https://bespoke-strudel-c243c pref("extensions.torlauncher.moat_service", "https://bridges.torproject.org/moat"); // Log levels +pref("browser.tor_provider.log_level", "Warn"); +pref("browser.tor_provider.cp_log_level", "Warn"); diff --git a/browser/installer/package-manifest.in b/browser/installer/package-manifest.in @@ -235,6 +235,9 @@ @RESPATH@/chrome/torbutton.manifest @RESPATH@/chrome/torbutton/* +; Tor integration +@RESPATH@/components/tor-launcher.manifest + ; [DevTools Startup Files] @RESPATH@/browser/chrome/devtools-startup@JAREXT@ @RESPATH@/browser/chrome/devtools-startup.manifest diff --git a/toolkit/components/moz.build b/toolkit/components/moz.build @@ -85,6 +85,7 @@ DIRS += [ "thumbnails", "timermanager", "tooltiptext", + "tor-launcher", "typeaheadfind", "utils", "url-classifier", diff --git a/toolkit/components/tor-launcher/TorBootstrapRequest.sys.mjs b/toolkit/components/tor-launcher/TorBootstrapRequest.sys.mjs @@ -0,0 +1,145 @@ +import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + TorProviderBuilder: "resource://gre/modules/TorProviderBuilder.sys.mjs", + TorProviderTopics: "resource://gre/modules/TorProviderBuilder.sys.mjs", +}); + +const log = console.createInstance({ + maxLogLevel: "Info", + prefix: "TorBootstrapRequest", +}); + +/** + * This class encapsulates the observer register/unregister logic to provide an + * XMLHttpRequest-like API to bootstrap tor. + * TODO: Remove this class, and move its logic inside the TorProvider. + */ +export class TorBootstrapRequest { + // number of ms to wait before we abandon the bootstrap attempt + // a value of 0 implies we never wait + timeout = 0; + + // callbacks for bootstrap process status updates + onbootstrapstatus = (_progress, _status) => {}; + onbootstrapcomplete = () => {}; + onbootstraperror = _error => {}; + + // internal resolve() method for bootstrap + #bootstrapPromiseResolve = null; + #bootstrapPromise = null; + #timeoutID = null; + + observe(subject, topic) { + const obj = subject?.wrappedJSObject; + switch (topic) { + case lazy.TorProviderTopics.BootstrapStatus: { + const progress = obj.PROGRESS; + if (this.onbootstrapstatus) { + const status = obj.TAG; + this.onbootstrapstatus(progress, status); + } + if (progress === 100) { + if (this.onbootstrapcomplete) { + this.onbootstrapcomplete(); + } + this.#bootstrapPromiseResolve(true); + clearTimeout(this.#timeoutID); + this.#timeoutID = null; + } + + break; + } + case lazy.TorProviderTopics.BootstrapError: { + log.info("TorBootstrapRequest: observerd TorBootstrapError", obj); + this.#stop(obj); + break; + } + } + } + + // resolves 'true' if bootstrap succeeds, false otherwise + bootstrap() { + if (this.#bootstrapPromise) { + return this.#bootstrapPromise; + } + + this.#bootstrapPromise = new Promise(resolve => { + this.#bootstrapPromiseResolve = resolve; + + // register ourselves to listen for bootstrap events + Services.obs.addObserver(this, lazy.TorProviderTopics.BootstrapStatus); + Services.obs.addObserver(this, lazy.TorProviderTopics.BootstrapError); + + // optionally cancel bootstrap after a given timeout + if (this.timeout > 0) { + this.#timeoutID = setTimeout(() => { + this.#timeoutID = null; + this.#stop( + new Error( + `Bootstrap attempt abandoned after waiting ${this.timeout} ms` + ) + ); + }, this.timeout); + } + + // Wait for bootstrapping to begin and maybe handle error. + // Notice that we do not resolve the promise here in case of success, but + // we do it from the BootstrapStatus observer. + // NOTE: After TorProviderBuilder.build resolves, TorProvider.init will + // have completed. In particular, assuming no errors, the TorSettings will + // have been initialised and passed on to the provider via + // TorProvider.writeSettings. Therefore we should be safe to immediately + // call `connect` using the latest user settings. + lazy.TorProviderBuilder.build() + .then(provider => provider.connect()) + .catch(err => { + this.#stop(err); + }); + }).finally(() => { + // and remove ourselves once bootstrap is resolved + Services.obs.removeObserver(this, lazy.TorProviderTopics.BootstrapStatus); + Services.obs.removeObserver(this, lazy.TorProviderTopics.BootstrapError); + this.#bootstrapPromise = null; + }); + + return this.#bootstrapPromise; + } + + async cancel() { + await this.#stop(); + } + + // Internal implementation. Do not use directly, but call cancel, instead. + async #stop(error) { + // first stop our bootstrap timeout before handling the error + if (this.#timeoutID !== null) { + clearTimeout(this.#timeoutID); + this.#timeoutID = null; + } + + let provider; + try { + provider = await lazy.TorProviderBuilder.build(); + } catch { + // This was probably the error that lead to stop in the first place. + // No need to continue propagating it. + } + try { + await provider?.stopBootstrap(); + } catch (e) { + console.error("Failed to stop the bootstrap.", e); + if (!error) { + error = e; + } + } + + if (this.onbootstraperror && error) { + this.onbootstraperror(error); + } + + this.#bootstrapPromiseResolve(false); + } +} diff --git a/toolkit/components/tor-launcher/TorControlPort.sys.mjs b/toolkit/components/tor-launcher/TorControlPort.sys.mjs @@ -0,0 +1,1348 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { TorParsers } from "resource://gre/modules/TorParsers.sys.mjs"; + +const logger = console.createInstance({ + maxLogLevelPref: "browser.tor_provider.cp_log_level", + prefix: "TorControlPort", +}); + +/** + * A wrapper around XPCOM sockets and buffers to handle streams in a standard + * async JS fashion. + * This class can handle both Unix sockets and TCP sockets. + */ +class AsyncSocket { + /** + * The output stream used for write operations. + * + * @type {nsIAsyncOutputStream} + */ + #outputStream; + /** + * The output stream can only have one registered callback at a time, so + * multiple writes need to be queued up (see nsIAsyncOutputStream.idl). + * Every item is associated with a promise we returned in write, and it will + * resolve it or reject it when called by the output stream. + * + * @type {nsIOutputStreamCallback[]} + */ + #outputQueue = []; + /** + * The input stream. + * + * @type {nsIAsyncInputStream} + */ + #inputStream; + /** + * An input stream adapter that makes reading from scripts easier. + * + * @type {nsIScriptableInputStream} + */ + #scriptableInputStream; + /** + * The queue of callbacks to be used when we receive data. + * Every item is associated with a promise we returned in read, and it will + * resolve it or reject it when called by the input stream. + * + * @type {nsIInputStreamCallback[]} + */ + #inputQueue = []; + + /** + * Connect to a Unix socket. Not available on Windows. + * + * @param {nsIFile} ipcFile The path to the Unix socket to connect to. + * @returns {AsyncSocket} + */ + static fromIpcFile(ipcFile) { + const sts = Cc[ + "@mozilla.org/network/socket-transport-service;1" + ].getService(Ci.nsISocketTransportService); + const socket = new AsyncSocket(); + const transport = sts.createUnixDomainTransport(ipcFile); + socket.#createStreams(transport); + return socket; + } + + /** + * Connect to a TCP socket. + * + * @param {string} host The hostname to connect the TCP socket to. + * @param {number} port The port to connect the TCP socket to. + * @returns {AsyncSocket} + */ + static fromSocketAddress(host, port) { + const sts = Cc[ + "@mozilla.org/network/socket-transport-service;1" + ].getService(Ci.nsISocketTransportService); + const socket = new AsyncSocket(); + const transport = sts.createTransport([], host, port, null, null); + socket.#createStreams(transport); + return socket; + } + + #createStreams(socketTransport) { + const OPEN_UNBUFFERED = Ci.nsITransport.OPEN_UNBUFFERED; + this.#outputStream = socketTransport + .openOutputStream(OPEN_UNBUFFERED, 1, 1) + .QueryInterface(Ci.nsIAsyncOutputStream); + + this.#inputStream = socketTransport + .openInputStream(OPEN_UNBUFFERED, 1, 1) + .QueryInterface(Ci.nsIAsyncInputStream); + this.#scriptableInputStream = Cc[ + "@mozilla.org/scriptableinputstream;1" + ].createInstance(Ci.nsIScriptableInputStream); + this.#scriptableInputStream.init(this.#inputStream); + } + + /** + * Asynchronously write string to underlying socket. + * + * When write is called, we create a new promise and queue it on the output + * queue. If it is the only element in the queue, we ask the output stream to + * run it immediately. + * Otherwise, the previous item of the queue will run it after it finishes. + * + * @param {string} str The string to write to the socket. The underlying + * implementation should convert JS strings (UTF-16) into UTF-8 strings. + * See also write nsIOutputStream (the first argument is a string, not a + * wstring). + * @returns {Promise<number>} The number of written bytes + */ + async write(str) { + return new Promise((resolve, reject) => { + // Asynchronously wait for the stream to be writable (or closed) if we + // have any pending requests. + const tryAsyncWait = () => { + if (this.#outputQueue.length) { + this.#outputStream.asyncWait( + this.#outputQueue.at(0), // next request + 0, + 0, + Services.tm.currentThread + ); + } + }; + + // Implement an nsIOutputStreamCallback: write the string once possible, + // and then start running the following queue item, if any. + this.#outputQueue.push({ + onOutputStreamReady: () => { + try { + const bytesWritten = this.#outputStream.write(str, str.length); + + // Remove this callback object from queue, as it is now completed. + this.#outputQueue.shift(); + + // Queue the next request if there is one. + tryAsyncWait(); + + // Finally, resolve the promise. + resolve(bytesWritten); + } catch (err) { + // Reject the promise on error. + reject(err); + } + }, + }); + + // Length 1 imples that there is no in-flight asyncWait, so we may + // immediately follow through on this write. + if (this.#outputQueue.length === 1) { + tryAsyncWait(); + } + }); + } + + /** + * Asynchronously read string from underlying socket and return it. + * + * When read is called, we create a new promise and queue it on the input + * queue. If it is the only element in the queue, we ask the input stream to + * run it immediately. + * Otherwise, the previous item of the queue will run it after it finishes. + * + * This function is expected to throw when the underlying socket has been + * closed. + * + * @returns {Promise<string>} The read string + */ + async read() { + return new Promise((resolve, reject) => { + const tryAsyncWait = () => { + if (this.#inputQueue.length) { + this.#inputStream.asyncWait( + this.#inputQueue.at(0), // next input request + 0, + 0, + Services.tm.currentThread + ); + } + }; + + this.#inputQueue.push({ + onInputStreamReady: () => { + try { + if (!this.#scriptableInputStream.available()) { + // This means EOF, but not closed yet. However, arriving at EOF + // should be an error condition for us, since we are in a socket, + // and EOF should mean peer disconnected. + // If the stream has been closed, this function itself should + // throw. + reject( + new Error("onInputStreamReady called without available bytes.") + ); + return; + } + + // Read our string from input stream. + const str = this.#scriptableInputStream.read( + this.#scriptableInputStream.available() + ); + + // Remove this callback object from queue now that we have read. + this.#inputQueue.shift(); + + // Start waiting for incoming data again if the reading queue is not + // empty. + tryAsyncWait(); + + // Finally resolve the promise. + resolve(str); + } catch (err) { + // E.g., we received a NS_BASE_STREAM_CLOSED because the socket was + // closed. + reject(err); + } + }, + }); + + // Length 1 imples that there is no in-flight asyncWait, so we may + // immediately follow through on this read. + if (this.#inputQueue.length === 1) { + tryAsyncWait(); + } + }); + } + + /** + * Close the streams. + */ + close() { + this.#outputStream.close(); + this.#inputStream.close(); + } +} + +/** + * @typedef Command + * @property {string} commandString The string to send over the control port + * @property {Function} resolve The function to resolve the promise with the + * response we got on the control port + * @property {Function} reject The function to reject the promise associated to + * the command + */ +/** + * The ID of a circuit. + * From control-spec.txt: + * CircuitID = 1*16 IDChar + * IDChar = ALPHA / DIGIT + * Currently, Tor only uses digits, but this may change. + * + * @typedef {string} CircuitID + */ +/** + * The ID of a stream. + * From control-spec.txt: + * CircuitID = 1*16 IDChar + * IDChar = ALPHA / DIGIT + * Currently, Tor only uses digits, but this may change. + * + * @typedef {string} StreamID + */ +/** + * The fingerprint of a node. + * From control-spec.txt: + * Fingerprint = "$" 40*HEXDIG + * However, we do not keep the $ in our structures. + * + * @typedef {string} NodeFingerprint + */ +/** + * @typedef {object} CircuitInfo + * @property {CircuitID} id The ID of a circuit + * @property {NodeFingerprint[]} nodes List of node fingerprints + */ +/** + * @typedef {object} Bridge + * @property {string} transport The transport of the bridge, or vanilla if not + * specified + * @property {string} addr The IP address and port of the bridge + * @property {NodeFingerprint} id The fingerprint of the bridge + * @property {string} args Optional arguments passed to the bridge + */ +/** + * @typedef {object} PTInfo The information about a pluggable transport + * @property {string[]} transports An array with all the transports supported by + * this configuration + * @property {string} type Either socks4, socks5 or exec + * @property {string} [ip] The IP address of the proxy (only for socks4 and + * socks5) + * @property {integer} [port] The port of the proxy (only for socks4 and socks5) + * @property {string} [pathToBinary] Path to the binary that is run (only for + * exec) + * @property {string} [options] Optional options passed to the binary (only for + * exec) + */ +/** + * @typedef {object} SocksListener + * @property {string} [ipcPath] path to a Unix socket to use for an IPC proxy + * @property {string} [host] The host to connect for a TCP proxy + * @property {number} [port] The port number to use for a TCP proxy + */ +/** + * @typedef {object} OnionAuthKeyInfo + * @property {string} address The address of the onion service + * @property {string} typeAndKey Onion service key and type of key, as + * `type:base64-private-key` + * @property {string} Flags Additional flags, such as Permanent + */ +/** + * @callback EventCallback A callback to receive messages from the control + * port. + * @param {string} message The message to handle + */ + +/** + * This is a custom error thrown when we receive an error response over the + * control port. + * + * It includes the command that caused it, the error code and the raw message + * sent by the tor daemon. + */ +class TorProtocolError extends Error { + constructor(command, reply) { + super(`${command} -> ${reply}`); + this.name = "TorProtocolError"; + const info = reply.match(/(?<code>\d{3})(?:\s(?<message>.+))?/); + this.torStatusCode = info.groups.code; + if (info.groups.message) { + this.torMessage = info.groups.message; + } + } +} + +/** + * This class implements a JavaScript API around some of the commands and + * notifications that can be sent and received with tor's control port protocol. + */ +export class TorController { + /** + * The socket to write to the control port. + * + * @type {AsyncSocket} + */ + #socket; + + /** + * Data we received on a read but that was not a complete line (missing a + * final CRLF). We will prepend it to the next read. + * + * @type {string} + */ + #pendingData = ""; + /** + * The lines we received and are still queued for being evaluated. + * + * @type {string[]} + */ + #pendingLines = []; + /** + * The commands that need to be run or receive a response. + * + * NOTE: This must be in the order with the last requested command at the end + * of the queue. + * + * @type {Command[]} + */ + #commandQueue = []; + + /** + * The event handler. + * + * @type {TorEventHandler} + */ + #eventHandler; + + /** + * Connect to a control port over a Unix socket. + * Not available on Windows. + * + * @param {nsIFile} ipcFile The path to the Unix socket to connect to + * @param {TorEventHandler} eventHandler The event handler to use for + * asynchronous notifications + * @returns {TorController} + */ + static fromIpcFile(ipcFile, eventHandler) { + return new TorController(AsyncSocket.fromIpcFile(ipcFile), eventHandler); + } + + /** + * Connect to a control port over a TCP socket. + * + * @param {string} host The hostname to connect to + * @param {number} port The port to connect the to + * @param {TorEventHandler} eventHandler The event handler to use for + * asynchronous notifications + * @returns {TorController} + */ + static fromSocketAddress(host, port, eventHandler) { + return new TorController( + AsyncSocket.fromSocketAddress(host, port), + eventHandler + ); + } + + /** + * Construct the controller and start the message pump. + * The class should not be constructed directly, but through static methods. + * However, this is public because JavaScript does not support private + * constructors. + * + * @private + * @param {AsyncSocket} socket The socket to use + * @param {TorEventHandler} eventHandler The event handler to use for + * asynchronous notifications + */ + constructor(socket, eventHandler) { + this.#socket = socket; + this.#eventHandler = eventHandler; + this.#startMessagePump(); + } + + // Socket and communication handling + + /** + * Return the next line in the queue. If there is not any, block until one is + * read (or until a communication error happens, including the underlying + * socket being closed while it was still waiting for data). + * Any letfovers will be prepended to the next read. + * + * @returns {Promise<string>} A line read over the socket + */ + async #readLine() { + // Keep reading from socket until we have at least a full line to return. + while (!this.#pendingLines.length) { + if (!this.#socket) { + throw new Error( + "Read interrupted because the control socket is not available anymore" + ); + } + // Read data from our socket and split on newline tokens. + // This might still throw when the socket has been closed. + this.#pendingData += await this.#socket.read(); + const lines = this.#pendingData.split("\r\n"); + // The last line will either be empty string, or a partial read of a + // response/event so save it off for the next socket read. + this.#pendingData = lines.pop(); + // Copy remaining full lines to our pendingLines list. + this.#pendingLines = this.#pendingLines.concat(lines); + } + return this.#pendingLines.shift(); + } + + /** + * Blocks until an entire message is ready and returns it. + * This function does a rudimentary parsing of the data only to handle + * multi-line responses. + * + * @returns {Promise<string>} The read message (without the final CRLF) + */ + async #readMessage() { + // Whether we are searching for the end of a multi-line values. + // See control-spec section 3.9. + let handlingMultlineValue = false; + let endOfMessageFound = false; + const message = []; + + do { + const line = await this.#readLine(); + message.push(line); + + if (handlingMultlineValue) { + // look for end of multiline + if (line === ".") { + handlingMultlineValue = false; + } + } else { + // 'Multiline values' are possible. We avoid interrupting one by + // detecting it and waiting for a terminating "." on its own line. + // (See control-spec section 3.9 and + // https://gitlab.torproject.org/tpo/applications/tor-browser/-/issues/16990#note_2625464). + // Ensure this is the first line of a new message + // eslint-disable-next-line no-lonely-if + if (message.length === 1 && line.match(/^\d\d\d\+.+?=$/)) { + handlingMultlineValue = true; + } + // Look for end of message (notice the space character at end of the + // regex!). + else if (line.match(/^\d\d\d /)) { + if (message.length === 1) { + endOfMessageFound = true; + } else { + const firstReplyCode = message[0].substring(0, 3); + const lastReplyCode = line.substring(0, 3); + endOfMessageFound = firstReplyCode === lastReplyCode; + } + } + } + } while (!endOfMessageFound); + + // join our lines back together to form one message + return message.join("\r\n"); + } + + /** + * Handles a message that was received as a reply to a command (i.e., all the + * messages that are not async notification messages starting with 650). + * + * @param {string} message The message to handle + */ + #handleCommandReply(message) { + const cmd = this.#commandQueue.shift(); + // We resolve also for messages that are failures for sure. The commands + // should always check the output. + cmd.resolve(message); + + // send next command if one is available + if (this.#commandQueue.length) { + this.#writeNextCommand(); + } + } + + /** + * Read messages on the socket and routed them to a dispatcher until the + * socket is open or some error happens (including the underlying socket being + * closed). + */ + async #startMessagePump() { + try { + // This while is inside the try block because it is very likely that it + // will be broken by a NS_BASE_STREAM_CLOSED exception, rather than by its + // condition becoming false. + while (this.#socket) { + const message = await this.#readMessage(); + try { + if (message.startsWith("650")) { + this.#handleNotification(message); + } else { + this.#handleCommandReply(message); + } + } catch (err) { + // E.g., if a notification handler fails. Without this internal + // try/catch we risk of closing the connection while not actually + // needed. + logger.error("Caught an exception while handling a message", err); + } + } + } catch (err) { + logger.debug("Caught an exception, closing the control port", err); + try { + this.#close(err); + } catch (ec) { + logger.error( + "Caught another error while closing the control socket.", + ec + ); + } + } + } + + /** + * Start running the first available command in the queue. + * To be called when the previous one has finished running. + * This makes sure to avoid conflicts when using the control port. + */ + #writeNextCommand() { + const cmd = this.#commandQueue[0]; + this.#socket.write(`${cmd.commandString}\r\n`).catch(cmd.reject); + } + + /** + * Send a command over the control port. + * This function returns only when it receives a complete message over the + * control port. This class does some rudimentary parsing to check wheter it + * needs to handle multi-line messages. + * + * @param {string} commandString The command to send + * @returns {Promise<string>} The message sent by the control port. The return + * value should never be an empty string (even though it will not include the + * final CRLF). + */ + async #sendCommand(commandString) { + if (!this.#socket) { + throw new Error("ControlSocket not open"); + } + + // this promise is resolved either in #handleCommandReply, or in + // #startMessagePump (on stream error) + return new Promise((resolve, reject) => { + const command = { + commandString, + resolve, + reject, + }; + this.#commandQueue.push(command); + if (this.#commandQueue.length === 1) { + this.#writeNextCommand(); + } + }); + } + + /** + * Send a simple command whose response is expected to be simply a "250 OK". + * The function will not return a reply, but will throw if an unexpected one + * is received. + * + * @param {string} command The command to send + */ + async #sendCommandSimple(command) { + const reply = await this.#sendCommand(command); + if (!/^250 OK\s*$/i.test(reply)) { + throw new TorProtocolError(command, reply); + } + } + + /** + * Reject all the commands that are still in queue and close the control + * socket. + * + * @param {object?} reason An error object used to pass a more specific + * rejection reason to the commands that are still queued. + */ + #close(reason) { + logger.info("Closing the control port", reason); + const error = new Error( + "The control socket has been closed" + + (reason ? `: ${reason.message}` : "") + ); + const commands = this.#commandQueue; + this.#commandQueue = []; + for (const cmd of commands) { + cmd.reject(error); + } + try { + this.#socket?.close(); + } finally { + this.#socket = null; + } + } + + /** + * Closes the socket connected to the control port. + */ + close() { + this.#close(null); + } + + /** + * Tells whether the underlying socket is still open. + * + * @returns {boolean} + */ + get isOpen() { + return !!this.#socket; + } + + /** + * Authenticate to the tor daemon. + * Notice that a failure in the authentication makes the connection close. + * + * @param {Uint8Array} password The password for the control port, as an array + * of bytes + */ + async authenticate(password) { + const passwordString = Array.from(password ?? [], b => + b.toString(16).padStart(2, "0") + ).join(""); + await this.#sendCommandSimple(`authenticate ${passwordString}`); + } + + // Information + + /** + * Sends a GETINFO for a single key. + * control-spec.txt says "one ReplyLine is sent for each requested value", so, + * we expect to receive only one line starting with `250-keyword=`, or one + * line starting with `250+keyword=` (in which case we will match until a + * period). + * This function could be possibly extended to handle several keys at once, + * but we currently do not need this functionality, so we preferred keeping + * the function simpler. + * + * @param {string} key The key to get value for + * @returns {Promise<string>} The string we received (only the value, without + * the key). We do not do any additional parsing on it + */ + async #getInfo(key) { + this.#expectString(key); + const cmd = `GETINFO ${key}`; + const reply = await this.#sendCommand(cmd); + const match = + reply.match(/^250-([^=]+)=(.*)$/m) || + reply.match(/^250\+([^=]+)=\r?\n(.*?)\r?\n^\.\r?\n^250 OK\s*$/ms); + if (!match || match[1] !== key) { + throw new TorProtocolError(cmd, reply); + } + return match[2]; + } + + /** + * Ask Tor its bootstrap phase. + * + * @returns {object} An object with the bootstrap information received from + * Tor. Its keys might vary, depending on the input + */ + async getBootstrapPhase() { + return this.#parseBootstrapStatus( + await this.#getInfo("status/bootstrap-phase") + ); + } + + /** + * Get the IPv4 and optionally IPv6 addresses of an onion router. + * + * @param {NodeFingerprint} id The fingerprint of the node the caller is + * interested in + * @returns {string[]} The IP addresses (one IPv4 and optionally an IPv6) + */ + async getNodeAddresses(id) { + this.#expectString(id, "id"); + const reply = await this.#getInfo(`ns/id/${id}`); + // See dir-spec.txt. + // r nickname identity digest publication IP OrPort DirPort + const rLine = reply.match(/^r\s+(.*)$/m); + const v4 = rLine ? rLine[1].split(/\s+/) : []; + // Tor should already reply with a 552 when a relay cannot be found. + // Also, publication is a date with a space inside, so it is counted twice. + if (!rLine || v4.length !== 8) { + throw new Error(`Received an invalid node information: ${reply}`); + } + const addresses = [v4[5]]; + // a address:port + // dir-spec.txt also states only the first one should be taken + const v6 = reply.match(/^a\s+\[([0-9a-fA-F:]+)\]:\d{1,5}$/m); + if (v6) { + addresses.push(v6[1]); + } + return addresses; + } + + /** + * Maps IP addresses to 2-letter country codes, or ?? if unknown. + * + * @param {string} ip The IP address to look for + * @returns {Promise<string>} A promise with the country code. If unknown, the + * promise is resolved with "??". It is rejected only when the underlying + * GETINFO command fails or if an exception is thrown + */ + async getIPCountry(ip) { + this.#expectString(ip, "ip"); + return this.#getInfo(`ip-to-country/${ip}`); + } + + /** + * Ask tor which ports it is listening to for SOCKS connections. + * + * @returns {Promise<SocksListener[]>} An array of addresses. It might be + * empty (e.g., when DisableNetwork is set) + */ + async getSocksListeners() { + const listeners = await this.#getInfo("net/listeners/socks"); + return Array.from( + listeners.matchAll(/\s*("(?:[^"\\]|\\.)*"|\S+)\s*/g), + m => { + const listener = TorParsers.unescapeString(m[1]); + if (listener.startsWith("unix:/")) { + return { ipcPath: listener.substring(5) }; + } + const idx = listener.lastIndexOf(":"); + const host = listener.substring(0, idx); + const port = parseInt(listener.substring(idx + 1)); + if (isNaN(port) || port <= 0 || port > 65535 || !host || !port) { + throw new Error(`Could not parse the SOCKS listener ${listener}.`); + } + return { host, port }; + } + ); + } + + /** + * Ask Tor a list of circuits. + * + * @returns {CircuitInfo[]} An array with a string for each line + */ + async getCircuits() { + const circuits = await this.#getInfo("circuit-status"); + return circuits + .split(/\r?\n/) + .map(this.#parseCircBuilt.bind(this)) + .filter(circ => circ); + } + + // Configuration + + /** + * Sends a GETCONF for a single key. + * The function could be easily generalized to get multiple keys at once, but + * we do not need this functionality, at the moment. + * + * @param {string} key The keys to get info for + * @returns {Promise<string[]>} The values obtained from the control port. + * The key is removed, and the values unescaped, but they are not parsed. + * The array might contain an empty string, which means that the default value + * is used + */ + async #getConf(key) { + this.#expectString(key, "key"); + // GETCONF expects a `keyword`, which should be only alpha characters, + // according to the definition in control-port.txt. But as a matter of fact, + // several configuration keys include numbers (e.g., Socks4Proxy). So, we + // accept also numbers in this regular expression. One of the reason to + // sanitize the input is that we then use it to create a regular expression. + // Sadly, JavaScript does not provide a function to escape/quote a string + // for inclusion in a regex. Should we remove this limitation, we should + // also implement a regex sanitizer, or switch to another pattern, like + // `([^=])` and then filter on the keyword. + if (!/^[A-Za-z0-9]+$/.test(key)) { + throw new Error("The key can be composed only of letters and numbers."); + } + const cmd = `GETCONF ${key}`; + const reply = await this.#sendCommand(cmd); + // From control-spec.txt: a 'default' value semantically different from an + // empty string will not have an equal sign, just `250 $key`. + const defaultRe = new RegExp(`^250[-\\s]${key}$`, "gim"); + if (reply.match(defaultRe)) { + return []; + } + const re = new RegExp(`^250[-\\s]${key}=(.*)$`, "gim"); + const values = Array.from(reply.matchAll(re), m => + TorParsers.unescapeString(m[1]) + ); + if (!values.length) { + throw new TorProtocolError(cmd, reply); + } + return values; + } + + /** + * Get the bridges Tor has been configured with. + * + * @returns {Bridge[]} The configured bridges + */ + async getBridges() { + let missingId = false; + const bridges = (await this.#getConf("BRIDGE")).map(line => { + const info = TorParsers.parseBridgeLine(line); + if (!info.id) { + missingId = true; + } + return info; + }); + + // tor-browser#42541: bridge lines are allowed not to have a fingerprint. + // If such a bridge is in use, we will fail to associate it to the circuits, + // and the circuit display will not be shown. + // Tor provides a couple of GETINFO commands we can try to use to get more + // data about bridges, in particular GETINFO ns/purpose/bridge. + // While it tells us the bridge's IP (as configured by the user, which might + // be different from the real one with some PTs such as Snowflake), it does + // not tell the pluggable transport. + // Therefore, we need to start from the configured bridge lines, and if we + // detect that a bridge does not have a fingerprint, we try to associate one + // through its IP address and port. + // However, users can set them directly, therefore we might end up setting + // a fingerprint to the wrong line (e.g., if the IP address is reused). + // Also, we are not sure about when the data of ns/purpose/bridge is + // populated. + // Usually, we are interested only in the data of currently active bridges + // for the circuit display. So, as a matter of fact, we expect to have + // entries and to expose only the correct and working data in the frontend. + if (missingId) { + // See https://spec.torproject.org/dir-spec/consensus-formats.html. + // r <name> <identity> <digest> <date> <time> <address> <orport> <dirport> + const info = (await this.#getInfo("ns/purpose/bridge")).matchAll( + /^r\s+\S+\s+(?<identity>\S+)\s+\S+\s+\S+\s+\S+\s+(?<address>\S+)\s+(?<orport>\d+)/gm + ); + const b64ToHex = b64 => { + let hex = ""; + const raw = atob(b64); + for (let i = 0; i < raw.length; i++) { + hex += raw.charCodeAt(i).toString(16).toUpperCase().padStart(2, "0"); + } + return hex; + }; + const knownBridges = new Map( + Array.from(info, m => [ + `${m.groups.address}:${m.groups.orport}`, + b64ToHex(m.groups.identity), + ]) + ); + for (const b of bridges) { + if (!b.id) { + // We expect the addresses of these lines to be only IPv4, therefore + // we do not check for brackets, even though they might be matched by + // our regex. + b.id = knownBridges.get(b.addr) ?? ""; + } + } + } + + return bridges; + } + + /** + * Get the configured pluggable transports. + * + * @returns {PTInfo[]} An array with the info of all the configured pluggable + * transports. + */ + async getPluggableTransports() { + return (await this.#getConf("ClientTransportPlugin")).map(ptLine => { + // man 1 tor: ClientTransportPlugin transport socks4|socks5 IP:PORT + const socksLine = ptLine.match( + /(\S+)\s+(socks[45])\s+([\d.]{7,15}|\[[\da-fA-F:]+\]):(\d{1,5})/i + ); + // man 1 tor: transport exec path-to-binary [options] + const execLine = ptLine.match( + /(\S+)\s+(exec)\s+("(?:[^"\\]|\\.)*"|\S+)\s*(.*)/i + ); + if (socksLine) { + return { + transports: socksLine[1].split(","), + type: socksLine[2].toLowerCase(), + ip: socksLine[3], + port: parseInt(socksLine[4], 10), + }; + } else if (execLine) { + return { + transports: execLine[1].split(","), + type: execLine[2].toLowerCase(), + pathToBinary: TorParsers.unescapeString(execLine[3]), + options: execLine[4], + }; + } + throw new Error( + `Received an invalid ClientTransportPlugin line: ${ptLine}` + ); + }); + } + + /** + * Send multiple configuration values to tor. + * + * @param {Array} values The values to set. It should be an array of + * [key, value] pairs to pass to SETCONF. Keys can be repeated, and array + * values will be automatically unrolled. + */ + async setConf(values) { + // NOTE: This is an async method. It must ensure that sequential calls to + // this method do not race against each other. I.e. the last call to this + // method must always be the last in #commandQueue. Otherwise a delayed + // earlier call could overwrite the configuration of a later call. + const args = values + .flatMap(([key, value]) => { + if (value === undefined || value === null) { + return [key]; + } + if (Array.isArray(value)) { + return value.length + ? value.map(v => `${key}=${TorParsers.escapeString(v)}`) + : key; + } else if (typeof value === "string" || value instanceof String) { + return `${key}=${TorParsers.escapeString(value)}`; + } else if (typeof value === "boolean") { + return `${key}=${value ? "1" : "0"}`; + } else if (typeof value === "number") { + return `${key}=${value}`; + } + throw new Error(`Unsupported type ${typeof value} (key ${key})`); + }) + .join(" "); + await this.#sendCommandSimple(`SETCONF ${args}`); + } + + /** + * Enable or disable the network. + * Notice: switching from network disabled to network enabled will trigger a + * bootstrap on C tor! (Or stop the current one). + * + * @param {boolean} enabled Tell whether the network should be enabled + */ + async setNetworkEnabled(enabled) { + await this.setConf([["DisableNetwork", !enabled]]); + } + + /** + * Ask Tor to write out its config options into its torrc. + */ + async flushSettings() { + await this.#sendCommandSimple("SAVECONF"); + } + + // Onion service authentication + + /** + * Sends a ONION_CLIENT_AUTH_VIEW command to retrieve the list of private + * keys. + * + * @returns {OnionAuthKeyInfo[]} + */ + async onionAuthViewKeys() { + const cmd = "onion_client_auth_view"; + const message = await this.#sendCommand(cmd); + // Either `250-CLIENT`, or `250 OK` if no keys are available. + if (!message.startsWith("250")) { + throw new TorProtocolError(cmd, message); + } + const re = + /^250-CLIENT\s+(?<HSAddress>[A-Za-z2-7]+)\s+(?<KeyType>[^:]+):(?<PrivateKeyBlob>\S+)(?:\s(?<other>.+))?$/gim; + return Array.from(message.matchAll(re), match => { + // TODO: Change the consumer and make the fields more consistent with what + // we get (e.g., separate key and type, and use a boolen for permanent). + const info = { + address: match.groups.HSAddress, + keyType: match.groups.KeyType, + keyBlob: match.groups.PrivateKeyBlob, + flags: [], + }; + const maybeFlags = match.groups.other?.match(/Flags=(\S+)/); + if (maybeFlags) { + info.flags = maybeFlags[1].split(","); + } + return info; + }); + } + + /** + * Sends an ONION_CLIENT_AUTH_ADD command to add a private key to the Tor + * configuration. + * + * @param {string} address The address of the onion service + * @param {string} b64PrivateKey The private key of the service, in base64 + * @param {boolean} isPermanent Tell whether the key should be saved forever + */ + async onionAuthAdd(address, b64PrivateKey, isPermanent) { + this.#expectString(address, "address"); + this.#expectString(b64PrivateKey, "b64PrivateKey"); + const keyType = "x25519"; + let cmd = `onion_client_auth_add ${address} ${keyType}:${b64PrivateKey}`; + if (isPermanent) { + cmd += " Flags=Permanent"; + } + const reply = await this.#sendCommand(cmd); + const status = reply.substring(0, 3); + if (status !== "250" && status !== "251" && status !== "252") { + throw new TorProtocolError(cmd, reply); + } + } + + /** + * Sends an ONION_CLIENT_AUTH_REMOVE command to remove a private key from the + * Tor configuration. + * + * @param {string} address The address of the onion service + */ + async onionAuthRemove(address) { + this.#expectString(address, "address"); + const cmd = `onion_client_auth_remove ${address}`; + const reply = await this.#sendCommand(cmd); + const status = reply.substring(0, 3); + if (status !== "250" && status !== "251") { + throw new TorProtocolError(cmd, reply); + } + } + + // Daemon ownership + + /** + * Instructs Tor to shut down when this control connection is closed. + * If multiple connection sends this request, Tor will shut dwon when any of + * them is closed. + */ + async takeOwnership() { + await this.#sendCommandSimple("TAKEOWNERSHIP"); + } + + /** + * The __OwningControllerProcess argument can be used to make Tor periodically + * check if a certain PID is still present, or terminate itself otherwise. + * When switching to the ownership tied to the control port, this mechanism + * should be stopped by calling this function. + */ + async resetOwningControllerProcess() { + await this.#sendCommandSimple("RESETCONF __OwningControllerProcess"); + } + + // Signals + + /** + * Ask Tor to swtich to new circuits and clear the DNS cache. + */ + async newnym() { + await this.#sendCommandSimple("SIGNAL NEWNYM"); + } + + // Events monitoring + + /** + * Enable receiving certain events. + * As per control-spec.txt, any events turned on in previous calls but not + * included in this one will be turned off. + * + * @param {string[]} types The events to enable. If empty, no events will be + * watched. + * @returns {Promise<void>} + */ + setEvents(types) { + if (!types.every(t => typeof t === "string" || t instanceof String)) { + throw new Error("Event types must be strings"); + } + return this.#sendCommandSimple("SETEVENTS " + types.join(" ")); + } + + /** + * Parse an asynchronous event and pass the data to the relative handler. + * Only single-line messages are currently supported. + * + * @param {string} message The message received on the control port. It should + * starts with `"650" SP`. + */ + #handleNotification(message) { + if (!this.#eventHandler) { + return; + } + const data = message.match(/^650\s+(?<type>\S+)\s*(?<data>.*)?/); + if (!data) { + return; + } + switch (data.groups.type) { + case "STATUS_CLIENT": { + let status; + try { + status = this.#parseBootstrapStatus(data.groups.data); + } catch (e) { + // Probably, a non bootstrap client status + logger.debug(`Failed to parse STATUS_CLIENT: ${data.groups.data}`, e); + break; + } + this.#eventHandler.onBootstrapStatus(status); + break; + } + case "CIRC": { + const maybeCircuit = this.#parseCircBuilt(data.groups.data); + const closedEvent = /^(?<ID>[a-zA-Z0-9]{1,16})\sCLOSED/.exec( + data.groups.data + ); + if (maybeCircuit) { + this.#eventHandler.onCircuitBuilt( + maybeCircuit.id, + maybeCircuit.nodes + ); + } else if (closedEvent) { + this.#eventHandler.onCircuitClosed(closedEvent.groups.ID); + } + break; + } + case "STREAM": { + const sentConnectEvent = + /^(?<StreamID>[a-zA-Z0-9]{1,16})\sSENTCONNECT\s(?<CircuitID>[a-zA-Z0-9]{1,16})/.exec( + data.groups.data + ); + if (sentConnectEvent) { + const credentials = this.#parseCredentials(data.groups.data); + this.#eventHandler.onStreamSentConnect( + sentConnectEvent.groups.StreamID, + sentConnectEvent.groups.CircuitID, + credentials?.username ?? null, + credentials?.password ?? null + ); + } + break; + } + case "NOTICE": + case "WARN": + case "ERR": + this.#eventHandler.onLogMessage(data.groups.type, data.groups.data); + break; + } + } + + // Parsers + + /** + * Parse a bootstrap status line. + * + * @param {string} line The line to parse, without the command/notification + * prefix + * @returns {object} An object with the bootstrap information received from + * Tor. Its keys might vary, depending on the input + */ + #parseBootstrapStatus(line) { + const match = line.match(/^(NOTICE|WARN) BOOTSTRAP\s*(.*)/); + if (!match) { + throw Error( + `Received an invalid response for the bootstrap phase: ${line}` + ); + } + const status = { + // Type is actually StatusSeverity in the specifications. + TYPE: match[1], + ...this.#getKeyValues(match[2]), + }; + if (status.PROGRESS !== undefined) { + status.PROGRESS = parseInt(status.PROGRESS, 10); + } + if (status.COUNT !== undefined) { + status.COUNT = parseInt(status.COUNT, 10); + } + return status; + } + + /** + * Parse a CIRC BUILT event or a GETINFO circuit-status. + * + * @param {string} line The line to parse + * @returns {CircuitInfo?} The ID and nodes of the circuit, or null if the + * parsing failed. + */ + #parseCircBuilt(line) { + const builtEvent = + /^(?<ID>[a-zA-Z0-9]{1,16})\sBUILT\s(?<Path>(,?\$([0-9a-fA-F]{40})(?:~[a-zA-Z0-9]{1,19})?)+)/.exec( + line + ); + if (!builtEvent) { + return null; + } + const fp = /\$([0-9a-fA-F]{40})/g; + const nodes = Array.from(builtEvent.groups.Path.matchAll(fp), g => + g[1].toUpperCase() + ); + // In some cases, we might already receive SOCKS credentials in the + // line. However, this might be a problem with Onion services: we get + // also a 4-hop circuit that we likely do not want to show to the + // user, especially because it is used only temporarily, and it would + // need a technical explaination. + // So we do not try to extract them for now. Otherwise, we could do + // const credentials = this.#parseCredentials(line); + return { id: builtEvent.groups.ID, nodes }; + } + + /** + * Check if a STREAM or CIRC response line contains SOCKS_USERNAME and + * SOCKS_PASSWORD. + * + * @param {string} line The circ or stream line to check + * @returns {object?} The credentials, or null if not found + */ + #parseCredentials(line) { + const username = /SOCKS_USERNAME=("(?:[^"\\]|\\.)*")/.exec(line); + const password = /SOCKS_PASSWORD=("(?:[^"\\]|\\.)*")/.exec(line); + return username && password + ? { + username: TorParsers.unescapeString(username[1]), + password: TorParsers.unescapeString(password[1]), + } + : null; + } + + /** + * Return an object with all the matches that are in the form `key="value"` or + * `key=value`. The values will be unescaped, but no additional parsing will + * be done (e.g., numbers will be returned as strings). + * If keys are repeated, only the last one will be taken. + * + * @param {string} str The string to match tokens in + * @returns {object} An object with all the various tokens. If none is found, + * an empty object is returned. + */ + #getKeyValues(str) { + return Object.fromEntries( + Array.from( + str.matchAll(/\s*([^=]+)=("(?:[^"\\]|\\.)*"|\S+)\s*/g) || [], + pair => [pair[1], TorParsers.unescapeString(pair[2])] + ) + ); + } + + // Other helpers + + /** + * Throw an exception when value is not a string. + * + * @param {any} value The value to check + * @param {string} name The name of the `value` argument + */ + #expectString(value, name) { + if (typeof value !== "string" && !(value instanceof String)) { + throw new Error(`The ${name} argument is expected to be a string.`); + } + } +} + +/** + * @typedef {object} TorEventHandler + * The event handler interface. + * The controller owner can implement this methods to receive asynchronous + * notifications from the controller. + * + * @property {OnBootstrapStatus} onBootstrapStatus Called when a bootstrap + * status is received (i.e., a STATUS_CLIENT event with a BOOTSTRAP action) + * @property {OnLogMessage} onLogMessage Called when a log message is received + * (i.e., a NOTICE, WARN or ERR notification) + * @property {OnCircuitBuilt} onCircuitBuilt Called when a circuit is built + * (i.e., a CIRC event with a BUILT status) + * @property {OnCircuitClosed} onCircuitClosed Called when a circuit is closed + * (i.e., a CIRC event with a CLOSED status) + * @property {OnStreamSentConnect} onStreamSentConnect Called when a stream sent + * a connect cell along a circuit (i.e., a STREAM event with a SENTCONNECT + * status) + */ +/** + * @callback OnBootstrapStatus + * + * @param {object} status An object with the bootstrap information. Its keys + * depend on what the arguments sent by the tor daemon + */ +/** + * @callback OnLogMessage + * + * @param {string} type The type of message (NOTICE, WARNING, ERR, etc...) + * @param {string} message The actual log message + */ +/** + * @callback OnCircuitBuilt + * + * @param {CircuitID} id The id of the circuit that has been built + * @param {NodeFingerprint[]} nodes The onion routers composing the circuit + */ +/** + * @callback OnCircuitClosed + * + * @param {CircuitID} id The id of the circuit that has been closed + */ +/** + * @callback OnStreamSentConnect + * + * @param {StreamID} streamId The id of the stream that switched to the succeeded + * state + * @param {CircuitID} circuitId The id of the circuit the stream is using + * @param {string?} username The SOCKS username associated to the stream, or + * null if not available + * @param {string?} username The SOCKS password associated to the stream, or + * null if not available + */ diff --git a/toolkit/components/tor-launcher/TorLauncherUtil.sys.mjs b/toolkit/components/tor-launcher/TorLauncherUtil.sys.mjs @@ -0,0 +1,702 @@ +// Copyright (c) 2022, The Tor Project, Inc. +// See LICENSE for licensing information. + +/************************************************************************* + * Tor Launcher Util JS Module + *************************************************************************/ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); + +const kPropBundleURI = "chrome://torbutton/locale/torlauncher.properties"; +const kPropNamePrefix = "torlauncher."; +const kIPCDirPrefName = "extensions.torlauncher.tmp_ipc_dir"; + +/** + * This class allows to lookup for the paths of the various files that are + * needed or can be used with the tor daemon, such as its configuration, the + * GeoIP databases, and the Unix sockets that can be optionally used for the + * control and the SOCKS ports. + */ +class TorFile { + // The nsIFile to be returned + file = null; + + isIPC = false; + ipcFileName = ""; + checkIPCPathLen = true; + + static _isFirstIPCPathRequest = true; + static _dataDir = null; + static _appDir = null; + static _torDir = null; + + constructor(aTorFileType, aCreate) { + this.fileType = aTorFileType; + + this.getFromPref(); + this.getIPC(); + // No preference and no pre-determined IPC path: use a default path. + if (!this.file) { + this.getDefault(); + } + // At this point, this.file must not be null, or previous functions must + // have thrown and interrupted this constructor. + if (!this.file.exists() && !this.isIPC && aCreate) { + this.createFile(); + } + this.normalize(); + } + + getFile() { + return this.file; + } + + getFromPref() { + const prefName = `extensions.torlauncher.${this.fileType}_path`; + const path = Services.prefs.getCharPref(prefName, ""); + if (path) { + const isUserData = + this.fileType !== "tor" && + this.fileType !== "pt-startup-dir" && + this.fileType !== "torrc-defaults"; + // always try to use path if provided in pref + this.checkIPCPathLen = false; + this.setFileFromPath(path, isUserData); + } + } + + getIPC() { + const isControlIPC = this.fileType === "control_ipc"; + const isSOCKSIPC = this.fileType === "socks_ipc"; + this.isIPC = isControlIPC || isSOCKSIPC; + if (!this.isIPC) { + return; + } + + const kControlIPCFileName = "control.socket"; + const kSOCKSIPCFileName = "socks.socket"; + this.ipcFileName = isControlIPC ? kControlIPCFileName : kSOCKSIPCFileName; + this.extraIPCPathLen = this.isSOCKSIPC ? 2 : 0; + + // Do not do anything else if this.file has already been populated with the + // _path preference for this file type (or if we are not looking for an IPC + // file). + if (this.file) { + return; + } + + // If this is the first request for an IPC path during this browser + // session, remove the old temporary directory. This helps to keep /tmp + // clean if the browser crashes or is killed. + if (TorFile._isFirstIPCPathRequest) { + TorLauncherUtil.cleanupTempDirectories(); + TorFile._isFirstIPCPathRequest = false; + } else { + // FIXME: Do we really need a preference? Or can we save it in a static + // member? + // Retrieve path for IPC objects (it may have already been determined). + const ipcDirPath = Services.prefs.getCharPref(kIPCDirPrefName, ""); + if (ipcDirPath) { + // We have already determined where IPC objects will be placed. + this.file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + this.file.initWithPath(ipcDirPath); + this.file.append(this.ipcFileName); + this.checkIPCPathLen = false; // already checked. + return; + } + } + + // If XDG_RUNTIME_DIR is set, use it as the base directory for IPC + // objects (e.g., Unix domain sockets) -- assuming it is not too long. + if (!Services.env.exists("XDG_RUNTIME_DIR")) { + return; + } + const ipcDir = this.createUniqueIPCDir(Services.env.get("XDG_RUNTIME_DIR")); + if (ipcDir) { + const f = ipcDir.clone(); + f.append(this.ipcFileName); + if (this.isIPCPathLengthOK(f.path, this.extraIPCPathLen)) { + this.file = f; + this.checkIPCPathLen = false; // no need to check again. + + // Store directory path so it can be reused for other IPC objects + // and so it can be removed during exit. + Services.prefs.setCharPref(kIPCDirPrefName, ipcDir.path); + } else { + // too long; remove the directory that we just created. + ipcDir.remove(false); + } + } + } + + getDefault() { + switch (this.fileType) { + case "tor": + this.file = TorFile.torDir; + this.file.append(TorLauncherUtil.isWindows ? "tor.exe" : "tor"); + break; + case "torrc-defaults": + if (TorLauncherUtil.isMac) { + this.file = TorFile.appDir; + this.file.appendRelativePath( + "Contents/Resources/TorBrowser/Tor/torrc-defaults" + ); + } else { + // FIXME: Should we move this file to the tor directory, in the other + // platforms, since it is not user data? + this.file = TorFile.torDataDir; + this.file.append("torrc-defaults"); + } + break; + case "torrc": + this.file = TorFile.torDataDir; + this.file.append("torrc"); + break; + case "tordatadir": + this.file = TorFile.torDataDir; + break; + case "toronionauthdir": + this.file = TorFile.torDataDir; + this.file.append("onion-auth"); + break; + case "pt-startup-dir": + // On macOS we specify different relative paths than on Linux and + // Windows + this.file = TorLauncherUtil.isMac ? TorFile.torDir : TorFile.appDir; + break; + default: + if (!TorLauncherUtil.isWindows && this.isIPC) { + this.setFileFromPath(`Tor/${this.ipcFileName}`, true); + break; + } + throw new Error("Unknown file type"); + } + } + + // This function is used to set this.file from a string that contains a path. + // As a matter of fact, it is used only when setting a path from preferences, + // or to set the default IPC paths. + setFileFromPath(path, isUserData) { + if (TorLauncherUtil.isWindows) { + path = path.replaceAll("/", "\\"); + } + // Turn 'path' into an absolute path when needed. + if (TorLauncherUtil.isPathRelative(path)) { + if (TorLauncherUtil.isMac) { + // On macOS, files are correctly separated because it was needed for the + // gatekeeper signing. + this.file = isUserData ? TorFile.dataDir : TorFile.appDir; + } else { + // Windows and Linux still use the legacy behavior. + // To avoid breaking old installations, let's just keep it. + this.file = TorFile.appDir; + this.file.append("TorBrowser"); + } + this.file.appendRelativePath(path); + } else { + this.file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + this.file.initWithPath(path); + } + } + + createFile() { + if ( + "tordatadir" == this.fileType || + "toronionauthdir" == this.fileType || + "pt-profiles-dir" == this.fileType + ) { + this.file.create(this.file.DIRECTORY_TYPE, 0o700); + } else { + this.file.create(this.file.NORMAL_FILE_TYPE, 0o600); + } + } + + // If the file exists or an IPC object was requested, normalize the path + // and return a file object. The control and SOCKS IPC objects will be + // created by tor. + normalize() { + if (this.file.exists()) { + try { + this.file.normalize(); + } catch (e) { + console.warn("Normalization of the path failed", e); + } + } else if (!this.isIPC) { + throw new Error(`${this.fileType} file not found: ${this.file.path}`); + } + + // Ensure that the IPC path length is short enough for use by the + // operating system. If not, create and use a unique directory under + // /tmp for all IPC objects. The created directory path is stored in + // a preference so it can be reused for other IPC objects and so it + // can be removed during exit. + if ( + this.isIPC && + this.checkIPCPathLen && + !this.isIPCPathLengthOK(this.file.path, this.extraIPCPathLen) + ) { + this.file = this.createUniqueIPCDir("/tmp"); + if (!this.file) { + throw new Error("failed to create unique directory under /tmp"); + } + + Services.prefs.setCharPref(kIPCDirPrefName, this.file.path); + this.file.append(this.ipcFileName); + } + } + + // Return true if aPath is short enough to be used as an IPC object path, + // e.g., for a Unix domain socket path. aExtraLen is the "delta" necessary + // to accommodate other IPC objects that have longer names; it is used to + // account for "control.socket" vs. "socks.socket" (we want to ensure that + // all IPC objects are placed in the same parent directory unless the user + // has set prefs or env vars to explicitly specify the path for an object). + // We enforce a maximum length of 100 because all operating systems allow + // at least 100 characters for Unix domain socket paths. + isIPCPathLengthOK(aPath, aExtraLen) { + const kMaxIPCPathLen = 100; + return aPath && aPath.length + aExtraLen <= kMaxIPCPathLen; + } + + // Returns an nsIFile or null if a unique directory could not be created. + createUniqueIPCDir(aBasePath) { + try { + const d = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + d.initWithPath(aBasePath); + d.append("Tor"); + d.createUnique(Ci.nsIFile.DIRECTORY_TYPE, 0o700); + return d; + } catch (e) { + console.error(`createUniqueIPCDir failed for ${aBasePath}: `, e); + return null; + } + } + + // Returns an nsIFile that points to the binary directory (on Linux and + // Windows), and to the root of the application bundle on macOS. + static get appDir() { + if (!this._appDir) { + // .../Browser on Windows and Linux, .../TorBrowser.app/Contents/MacOS/ on + // macOS. + this._appDir = Services.dirsvc.get("XREExeF", Ci.nsIFile).parent; + if (TorLauncherUtil.isMac) { + this._appDir = this._appDir.parent.parent; + } + } + return this._appDir.clone(); + } + + // Returns an nsIFile that points to the data directory. This is usually + // TorBrowser/Data/ on Linux and Windows, and TorBrowser-Data/ on macOS. + // The parent directory of the default profile directory is taken. + static get dataDir() { + if (!this._dataDir) { + // Notice that we use `DefProfRt`, because users could create their + // profile in a completely unexpected directory: the profiles.ini contains + // a IsRelative entry, which I expect could influence ProfD, but not this. + this._dataDir = Services.dirsvc.get("DefProfRt", Ci.nsIFile).parent; + } + return this._dataDir.clone(); + } + + // Returns an nsIFile that points to the directory that contains the tor + // executable. + static get torDir() { + if (!this._torDir) { + // The directory that contains firefox + const torDir = Services.dirsvc.get("XREExeF", Ci.nsIFile).parent; + if (!TorLauncherUtil.isMac) { + torDir.append("TorBrowser"); + } + torDir.append("Tor"); + // Save the value only if the XPCOM methods do not throw. + this._torDir = torDir; + } + return this._torDir.clone(); + } + + // Returns an nsIFile that points to the directory that contains the tor + // data. Currently it is ${dataDir}/Tor. + static get torDataDir() { + const dir = this.dataDir; + dir.append("Tor"); + return dir; + } +} + +export const TorLauncherUtil = { + get isAndroid() { + return Services.appinfo.OS === "Android"; + }, + + get isLinux() { + // Use AppConstants for Linux rather then appinfo because we are sure it + // will catch also various Unix flavors for which unofficial ports might + // exist (which should work as Linux, as far as we know). + return AppConstants.platform === "linux"; + }, + + get isMac() { + return Services.appinfo.OS === "Darwin"; + }, + + get isWindows() { + return Services.appinfo.OS === "WINNT"; + }, + + isPathRelative(path) { + const re = this.isWindows ? /^([A-Za-z]:|\\)\\/ : /^\//; + return !re.test(path); + }, + + // Returns true if user confirms; false if not. + showConfirm(aParentWindow, aMsg, aDefaultButtonLabel, aCancelButtonLabel) { + if (!aParentWindow) { + aParentWindow = Services.wm.getMostRecentWindow("navigator:browser"); + } + + const ps = Services.prompt; + const title = this.getLocalizedString("error_title"); + const btnFlags = + ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING + + ps.BUTTON_POS_0_DEFAULT + + ps.BUTTON_POS_1 * ps.BUTTON_TITLE_IS_STRING; + + const notUsed = { value: false }; + const btnIndex = ps.confirmEx( + aParentWindow, + title, + aMsg, + btnFlags, + aDefaultButtonLabel, + aCancelButtonLabel, + null, + null, + notUsed + ); + return btnIndex === 0; + }, + + /** + * Ask the user whether they desire to restart tor. + * + * @param {boolean} initError If we could connect to the control port at + * least once and we are showing this prompt because the tor process exited + * suddenly, we will display a different message + * @returns {boolean} true if the user asked to restart tor + */ + showRestartPrompt(initError) { + let s; + if (initError) { + const key = "tor_exited_during_startup"; + s = this.getLocalizedString(key); + } else { + // tor exited suddenly, so configuration should be okay + s = + this.getLocalizedString("tor_exited") + + "\n\n" + + this.getLocalizedString("tor_exited2"); + } + const defaultBtnLabel = this.getLocalizedString("restart_tor"); + let cancelBtnLabel = "OK"; + try { + const kSysBundleURI = "chrome://global/locale/commonDialogs.properties"; + const sysBundle = Services.strings.createBundle(kSysBundleURI); + cancelBtnLabel = sysBundle.GetStringFromName(cancelBtnLabel); + } catch (e) { + console.warn("Could not localize the cancel button", e); + } + return this.showConfirm(null, s, defaultBtnLabel, cancelBtnLabel); + }, + + _stringBundle: null, + + // Localized Strings + // TODO: Switch to fluent also these ones. + + // "torlauncher." is prepended to aStringName. + getLocalizedString(aStringName) { + if (!aStringName) { + return aStringName; + } + if (!this._stringBundle) { + this._stringBundle = Services.strings.createBundle(kPropBundleURI); + } + try { + const key = kPropNamePrefix + aStringName; + return this._stringBundle.GetStringFromName(key); + } catch (e) {} + return aStringName; + }, + + /** + * Determine what kind of SOCKS port has been requested for this session or + * the browser has been configured for. + * On Windows (where Unix domain sockets are not supported), TCP is always + * used. + * + * The following environment variables are supported and take precedence over + * preferences: + * TOR_TRANSPROXY (do not use a proxy) + * TOR_SOCKS_IPC_PATH (file system path; ignored on Windows) + * TOR_SOCKS_HOST + * TOR_SOCKS_PORT + * + * The following preferences are consulted: + * network.proxy.socks + * network.proxy.socks_port + * extensions.torlauncher.socks_port_use_ipc (Boolean) + * extensions.torlauncher.socks_ipc_path (file system path) + * If extensions.torlauncher.socks_ipc_path is empty, a default path is used. + * + * When using TCP, if a value is not defined via an env variable it is + * taken from the corresponding browser preference if possible. The + * exceptions are: + * If network.proxy.socks contains a file: URL, a default value of + * "127.0.0.1" is used instead. + * If the network.proxy.socks_port value is not valid (outside the + * (0; 65535] range), we will let the tor daemon choose a port. + * + * The SOCKS configuration will not influence the launch of a tor daemon and + * the configuration of the control port in any way. + * When a SOCKS configuration is required without TOR_SKIP_LAUNCH, the browser + * will try to configure the tor instance to use the required configuration. + * This also applies to TOR_TRANSPROXY (at least for now): tor will be + * launched with its defaults. + * + * @returns {SocksSettings} + */ + getPreferredSocksConfiguration() { + if (Services.env.exists("TOR_TRANSPROXY")) { + return { transproxy: true }; + } + + let useIPC; + const socksPortInfo = { + transproxy: false, + }; + + if (!this.isWindows && Services.env.exists("TOR_SOCKS_IPC_PATH")) { + useIPC = true; + const ipcPath = Services.env.get("TOR_SOCKS_IPC_PATH"); + if (ipcPath) { + socksPortInfo.ipcFile = new lazy.FileUtils.File(ipcPath); + } + } else { + // Check for TCP host and port environment variables. + if (Services.env.exists("TOR_SOCKS_HOST")) { + socksPortInfo.host = Services.env.get("TOR_SOCKS_HOST"); + useIPC = false; + } + if (Services.env.exists("TOR_SOCKS_PORT")) { + const port = parseInt(Services.env.get("TOR_SOCKS_PORT"), 10); + if (Number.isInteger(port) && port >= 0 && port <= 65535) { + socksPortInfo.port = port; + useIPC = false; + } + } + } + + if (useIPC === undefined) { + useIPC = + !this.isWindows && + Services.prefs.getBoolPref( + "extensions.torlauncher.socks_port_use_ipc", + false + ); + } + + // Fill in missing SOCKS info from prefs. + if (useIPC) { + if (!socksPortInfo.ipcFile) { + socksPortInfo.ipcFile = TorLauncherUtil.getTorFile("socks_ipc", false); + } + } else { + if (!socksPortInfo.host) { + let socksAddr = Services.prefs.getCharPref( + "network.proxy.socks", + "127.0.0.1" + ); + let socksAddrHasHost = socksAddr && !socksAddr.startsWith("file:"); + socksPortInfo.host = socksAddrHasHost ? socksAddr : "127.0.0.1"; + } + + if (socksPortInfo.port === undefined) { + let socksPort = Services.prefs.getIntPref( + "network.proxy.socks_port", + 9150 + ); + if (socksPort > 0 && socksPort <= 65535) { + socksPortInfo.port = socksPort; + } else { + // Automatic port number, we have to query tor over the control port + // every time we change DisableNetwork. + socksPortInfo.port = 0; + } + } + } + + return socksPortInfo; + }, + + /** + * Apply our proxy configuration to the browser. + * + * Currently, we try to configure the Tor daemon to match the browser's + * configuration, but this might change in the future (tor-browser#42062). + * + * @param {SocksSettings} socksPortInfo The configuration to apply + */ + setProxyConfiguration(socksPortInfo) { + if (socksPortInfo.transproxy) { + Services.prefs.setBoolPref("network.proxy.socks_remote_dns", false); + Services.prefs.setIntPref("network.proxy.type", 0); + Services.prefs.setIntPref("network.proxy.socks_port", 0); + Services.prefs.setCharPref("network.proxy.socks", ""); + return; + } + + if (socksPortInfo.ipcFile) { + const fph = Services.io + .getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler); + const fileURI = fph.newFileURI(socksPortInfo.ipcFile); + Services.prefs.setCharPref("network.proxy.socks", fileURI.spec); + Services.prefs.setIntPref("network.proxy.socks_port", 0); + } else { + if (socksPortInfo.host) { + Services.prefs.setCharPref("network.proxy.socks", socksPortInfo.host); + } + if (socksPortInfo.port > 0 && socksPortInfo.port <= 65535) { + Services.prefs.setIntPref( + "network.proxy.socks_port", + socksPortInfo.port + ); + } + } + + if (socksPortInfo.ipcFile || socksPortInfo.host || socksPortInfo.port) { + Services.prefs.setBoolPref("network.proxy.socks_remote_dns", true); + Services.prefs.setIntPref("network.proxy.type", 1); + } + + // Force prefs to be synced to disk + Services.prefs.savePrefFile(null); + }, + + /** + * Determine the current value for whether we should start and own Tor. + * + * @returns {boolean} Whether we should start and own Tor. + */ + _getShouldStartAndOwnTor() { + const kPrefStartTor = "extensions.torlauncher.start_tor"; + try { + const kBrowserToolboxPort = "MOZ_BROWSER_TOOLBOX_PORT"; + const kEnvSkipLaunch = "TOR_SKIP_LAUNCH"; + const kEnvProvider = "TOR_PROVIDER"; + if (Services.env.exists(kBrowserToolboxPort)) { + return false; + } + if (Services.env.exists(kEnvSkipLaunch)) { + const value = parseInt(Services.env.get(kEnvSkipLaunch)); + return isNaN(value) || !value; + } + if ( + Services.env.exists(kEnvProvider) && + Services.env.get(kEnvProvider) === "none" + ) { + return false; + } + } catch (e) {} + return Services.prefs.getBoolPref(kPrefStartTor, true); + }, + + /** + * Cached value for shouldStartAndOwnTor, or `null` if not yet initialised. + * + * @type {boolean} + */ + _shouldStartAndOwnTor: null, + + /** + * Whether we should start and own Tor. + * + * The value should be constant per-session. + * + * @type {boolean} + */ + get shouldStartAndOwnTor() { + // Do not want this value to change within the same session, so always used + // the cached valued if it is available. + if (this._shouldStartAndOwnTor === null) { + this._shouldStartAndOwnTor = this._getShouldStartAndOwnTor(); + } + return this._shouldStartAndOwnTor; + }, + + get shouldShowNetworkSettings() { + try { + const kEnvForceShowNetConfig = "TOR_FORCE_NET_CONFIG"; + if (Services.env.exists(kEnvForceShowNetConfig)) { + const value = parseInt(Services.env.get(kEnvForceShowNetConfig)); + return !isNaN(value) && value; + } + } catch (e) {} + return true; + }, + + get shouldOnlyConfigureTor() { + const kPrefOnlyConfigureTor = "extensions.torlauncher.only_configure_tor"; + try { + const kEnvOnlyConfigureTor = "TOR_CONFIGURE_ONLY"; + if (Services.env.exists(kEnvOnlyConfigureTor)) { + const value = parseInt(Services.env.get(kEnvOnlyConfigureTor)); + return !isNaN(value) && value; + } + } catch (e) {} + return Services.prefs.getBoolPref(kPrefOnlyConfigureTor, false); + }, + + // Returns an nsIFile. + // If aTorFileType is "control_ipc" or "socks_ipc", aCreate is ignored + // and there is no requirement that the IPC object exists. + // For all other file types, null is returned if the file does not exist + // and it cannot be created (it will be created if aCreate is true). + getTorFile(aTorFileType, aCreate) { + if (!aTorFileType) { + return null; + } + try { + const torFile = new TorFile(aTorFileType, aCreate); + return torFile.getFile(); + } catch (e) { + console.error(`getTorFile: cannot get ${aTorFileType}`, e); + } + return null; // File not found or error (logged above). + }, + + cleanupTempDirectories() { + const dirPath = Services.prefs.getCharPref(kIPCDirPrefName, ""); + try { + Services.prefs.clearUserPref(kIPCDirPrefName); + } catch (e) {} + try { + if (dirPath) { + const f = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + f.initWithPath(dirPath); + if (f.exists()) { + f.remove(false); + } + } + } catch (e) { + console.warn("Could not remove the IPC directory", e); + } + }, +}; diff --git a/toolkit/components/tor-launcher/TorParsers.sys.mjs b/toolkit/components/tor-launcher/TorParsers.sys.mjs @@ -0,0 +1,122 @@ +// Copyright (c) 2022, The Tor Project, Inc. + +export const TorStatuses = Object.freeze({ + OK: 250, + EventNotification: 650, +}); + +export const TorParsers = Object.freeze({ + // Escape non-ASCII characters for use within the Tor Control protocol. + // Based on Vidalia's src/common/stringutil.cpp:string_escape(). + // Returns the new string. + escapeString(aStr) { + // Just return if all characters are printable ASCII excluding SP, ", and # + const kSafeCharRE = /^[\x21\x24-\x7E]*$/; + if (!aStr || kSafeCharRE.test(aStr)) { + return aStr; + } + const escaped = aStr + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("\n", "\\n") + .replaceAll("\r", "\\r") + .replaceAll("\t", "\\t") + .replaceAll(/[^\x20-\x7e]+/g, text => { + const encoder = new TextEncoder(); + return Array.from( + encoder.encode(text), + ch => "\\x" + ch.toString(16) + ).join(""); + }); + return `"${escaped}"`; + }, + + // Unescape Tor Control string aStr (removing surrounding "" and \ escapes). + // Based on Vidalia's src/common/stringutil.cpp:string_unescape(). + // Returns the unescaped string. Throws upon failure. + // Within Torbutton, the file modules/utils.js also contains a copy of + // _strUnescape(). + unescapeString(aStr) { + if ( + !aStr || + aStr.length < 2 || + aStr[0] !== '"' || + aStr[aStr.length - 1] !== '"' + ) { + return aStr; + } + + // Regular expression by Tim Pietzcker + // https://stackoverflow.com/a/15569588 + if (!/^(?:[^"\\]|\\.|"(?:\\.|[^"\\])*")*$/.test(aStr)) { + throw new Error('Unescaped " within string'); + } + + const matchUnicode = /^(\\x[0-9A-Fa-f]{2}|\\[0-7]{3})+/; + let rv = ""; + let lastAdded = 1; + let bs; + while ((bs = aStr.indexOf("\\", lastAdded)) !== -1) { + rv += aStr.substring(lastAdded, bs); + // We always increment lastAdded, because we will either add something, or + // ignore the backslash. + lastAdded = bs + 2; + if (lastAdded === aStr.length) { + // The string ends with \", which is illegal + throw new Error("Missing character after \\"); + } + switch (aStr[bs + 1]) { + case "n": + rv += "\n"; + break; + case "r": + rv += "\r"; + break; + case "t": + rv += "\t"; + break; + case '"': + case "\\": + rv += aStr[bs + 1]; + break; + default: + aStr.substring(bs).replace(matchUnicode, sequence => { + const bytes = []; + for (let i = 0; i < sequence.length; i += 4) { + if (sequence[i + 1] === "x") { + bytes.push(parseInt(sequence.substring(i + 2, i + 4), 16)); + } else { + bytes.push(parseInt(sequence.substring(i + 1, i + 4), 8)); + } + } + lastAdded = bs + sequence.length; + const decoder = new TextDecoder(); + rv += decoder.decode(new Uint8Array(bytes)); + return ""; + }); + // We have already incremented lastAdded, which means we ignore the + // backslash, and we will do something at the next one. + break; + } + } + rv += aStr.substring(lastAdded, aStr.length - 1); + return rv; + }, + + parseBridgeLine(line) { + if (!line) { + return null; + } + const re = + /\s*(?:(?<transport>\S+)\s+)?(?<addr>[0-9a-fA-F\.\[\]\:]+:\d{1,5})(?:\s+(?<id>[0-9a-fA-F]{40}))?(?:\s+(?<args>.+))?/; + const match = re.exec(line); + if (!match) { + throw new Error(`Invalid bridge line: ${line}.`); + } + const bridge = match.groups; + if (!bridge.transport) { + bridge.transport = "vanilla"; + } + return bridge; + }, +}); diff --git a/toolkit/components/tor-launcher/TorProcess.sys.mjs b/toolkit/components/tor-launcher/TorProcess.sys.mjs @@ -0,0 +1,393 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { setTimeout } from "resource://gre/modules/Timer.sys.mjs"; +import { Subprocess } from "resource://gre/modules/Subprocess.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + TorLauncherUtil: "resource://gre/modules/TorLauncherUtil.sys.mjs", + TorParsers: "resource://gre/modules/TorParsers.sys.mjs", +}); + +const TorProcessStatus = Object.freeze({ + Unknown: 0, + Starting: 1, + Running: 2, + Exited: 3, +}); + +const logger = console.createInstance({ + maxLogLevel: "Info", + prefix: "TorProcess", +}); + +/** + * This class can be used to start a tor daemon instance and receive + * notifications when it exits. + * It will automatically convert the settings objects into the appropriate + * command line arguments. + * + * It does not offer a way to stop a process because it is supposed to exit + * automatically when the owning control port connection is closed. + */ +export class TorProcess { + #controlSettings; + #socksSettings; + #exeFile = null; + #dataDir = null; + #args = []; + #subprocess = null; + #status = TorProcessStatus.Unknown; + + onExit = _exitCode => {}; + + constructor(controlSettings, socksSettings) { + if ( + controlSettings && + !controlSettings.password?.length && + !controlSettings.cookieFilePath + ) { + throw new Error("Unauthenticated control port is not supported"); + } + + const checkPort = (port, allowZero) => + port === undefined || + (Number.isInteger(port) && + port < 65535 && + (port > 0 || (allowZero && port === 0))); + if (!checkPort(controlSettings?.port, false)) { + throw new Error("Invalid control port"); + } + // Port 0 for SOCKS means automatic port. + if (!checkPort(socksSettings.port, true)) { + throw new Error("Invalid port specified for the SOCKS port"); + } + + this.#controlSettings = { ...controlSettings }; + const ipcFileToString = file => + "unix:" + lazy.TorParsers.escapeString(file.path); + if (controlSettings.ipcFile) { + this.#controlSettings.ipcFile = ipcFileToString(controlSettings.ipcFile); + } + this.#socksSettings = { ...socksSettings }; + if (socksSettings.ipcFile) { + this.#socksSettings.ipcFile = ipcFileToString(socksSettings.ipcFile); + } + } + + get isRunning() { + return ( + this.#status === TorProcessStatus.Starting || + this.#status === TorProcessStatus.Running + ); + } + + async start() { + if (this.#subprocess) { + return; + } + + this.#status = TorProcessStatus.Unknown; + + try { + this.#makeArgs(); + this.#addControlPortArgs(); + this.#addSocksPortArg(); + + const pid = Services.appinfo.processID; + if (pid !== 0) { + this.#args.push("__OwningControllerProcess", pid.toString()); + } + + if (lazy.TorLauncherUtil.shouldShowNetworkSettings) { + this.#args.push("DisableNetwork", "1"); + } + + this.#status = TorProcessStatus.Starting; + + // useful for simulating slow tor daemon launch + const kPrefTorDaemonLaunchDelay = "extensions.torlauncher.launch_delay"; + const launchDelay = Services.prefs.getIntPref( + kPrefTorDaemonLaunchDelay, + 0 + ); + if (launchDelay > 0) { + await new Promise(resolve => setTimeout(() => resolve(), launchDelay)); + } + + logger.debug(`Starting ${this.#exeFile.path}`, this.#args); + const options = { + command: this.#exeFile.path, + arguments: this.#args, + stderr: "stdout", + workdir: lazy.TorLauncherUtil.getTorFile("pt-startup-dir", false).path, + }; + if (lazy.TorLauncherUtil.isLinux) { + let ldLibPath = Services.env.get("LD_LIBRARY_PATH") ?? ""; + if (ldLibPath) { + ldLibPath = ":" + ldLibPath; + } + options.environment = { + LD_LIBRARY_PATH: this.#exeFile.parent.path + ldLibPath, + }; + options.environmentAppend = true; + } + this.#subprocess = await Subprocess.call(options); + this.#status = TorProcessStatus.Running; + } catch (e) { + this.#status = TorProcessStatus.Exited; + this.#subprocess = null; + logger.error("startTor error:", e); + throw e; + } + + // Do not await the following functions, as they will return only when the + // process exits. + this.#dumpStdout(); + this.#watchProcess(); + } + + // Forget about a process. + // + // Instead of killing the tor process, we rely on the TAKEOWNERSHIP feature + // to shut down tor when we close the control port connection. + // + // Previously, we sent a SIGNAL HALT command to the tor control port, + // but that caused hangs upon exit in the Firefox 24.x based browser. + // Apparently, Firefox does not like to process socket I/O while + // quitting if the browser did not finish starting up (e.g., when + // someone presses the Quit button on our Network Settings window + // during startup). + // + // Still, before closing the owning connection, this class should forget about + // the process, so that future notifications will be ignored. + forget() { + this.#subprocess = null; + this.#status = TorProcessStatus.Exited; + } + + async #dumpStdout() { + let string; + while ( + this.#subprocess && + (string = await this.#subprocess.stdout.readString()) + ) { + dump(string); + } + } + + async #watchProcess() { + const watched = this.#subprocess; + if (!watched) { + return; + } + let processExitCode; + try { + const { exitCode } = await watched.wait(); + processExitCode = exitCode; + + if (watched !== this.#subprocess) { + logger.debug(`A Tor process exited with code ${exitCode}.`); + } else if (exitCode) { + logger.warn(`The watched Tor process exited with code ${exitCode}.`); + } else { + logger.info("The Tor process exited."); + } + } catch (e) { + logger.error("Failed to watch the tor process", e); + } + + if (watched === this.#subprocess) { + this.#processExitedUnexpectedly(processExitCode); + } + } + + #processExitedUnexpectedly(exitCode) { + this.#subprocess = null; + this.#status = TorProcessStatus.Exited; + logger.warn("Tor exited suddenly."); + this.onExit(exitCode); + } + + #makeArgs() { + this.#exeFile = lazy.TorLauncherUtil.getTorFile("tor", false); + if (!this.#exeFile) { + throw new Error("Could not find the tor binary."); + } + const torrcFile = lazy.TorLauncherUtil.getTorFile("torrc", true); + if (!torrcFile) { + // FIXME: Is this still a fatal error? + throw new Error("Could not find the torrc."); + } + // Get the Tor data directory first so it is created before we try to + // construct paths to files that will be inside it. + this.#dataDir = lazy.TorLauncherUtil.getTorFile("tordatadir", true); + if (!this.#dataDir) { + throw new Error("Could not find the tor data directory."); + } + const onionAuthDir = lazy.TorLauncherUtil.getTorFile( + "toronionauthdir", + true + ); + if (!onionAuthDir) { + throw new Error("Could not find the tor onion authentication directory."); + } + + this.#args = []; + this.#args.push("-f", torrcFile.path); + this.#args.push("DataDirectory", this.#dataDir.path); + this.#args.push("ClientOnionAuthDir", onionAuthDir.path); + + // TODO: Create this starting from pt_config.json (tor-browser#42357). + const torrcDefaultsFile = lazy.TorLauncherUtil.getTorFile( + "torrc-defaults", + false + ); + if (torrcDefaultsFile) { + this.#args.push("--defaults-torrc", torrcDefaultsFile.path); + // The geoip and geoip6 files are in the same directory as torrc-defaults. + // TODO: Change TorFile to return the generic path to these files to make + // them independent from the torrc-defaults. + const geoipFile = torrcDefaultsFile.clone(); + geoipFile.leafName = "geoip"; + this.#args.push("GeoIPFile", geoipFile.path); + const geoip6File = torrcDefaultsFile.clone(); + geoip6File.leafName = "geoip6"; + this.#args.push("GeoIPv6File", geoip6File.path); + } else { + logger.warn( + "torrc-defaults was not found, some functionalities will be disabled." + ); + } + } + + /** + * Add all the arguments related to the control port. + * We use the + prefix so that the the port is added to any other port already + * defined in the torrc, and the __ prefix so that it is never written to + * torrc. + */ + #addControlPortArgs() { + if (!this.#controlSettings) { + return; + } + + let controlPortArg; + if (this.#controlSettings.ipcFile) { + controlPortArg = this.#controlSettings.ipcFile; + } else if (this.#controlSettings.port) { + controlPortArg = this.#controlSettings.host + ? `${this.#controlSettings.host}:${this.#controlSettings.port}` + : this.#controlSettings.port.toString(); + } + if (controlPortArg) { + this.#args.push("+__ControlPort", controlPortArg); + } + + if (this.#controlSettings.password?.length) { + this.#args.push( + "HashedControlPassword", + this.#hashPassword(this.#controlSettings.password) + ); + } + if (this.#controlSettings.cookieFilePath) { + this.#args.push("CookieAuthentication", "1"); + this.#args.push("CookieAuthFile", this.#controlSettings.cookieFilePath); + } + } + + /** + * Add the argument related to the control port. + * We use the + prefix so that the the port is added to any other port already + * defined in the torrc, and the __ prefix so that it is never written to + * torrc. + */ + #addSocksPortArg() { + let socksPortArg; + if (this.#socksSettings.ipcFile) { + socksPortArg = this.#socksSettings.ipcFile; + } else if (this.#socksSettings.port > 0) { + socksPortArg = this.#socksSettings.host + ? `${this.#socksSettings.host}:${this.#socksSettings.port}` + : this.#socksSettings.port.toString(); + } else { + socksPortArg = "auto"; + } + if (socksPortArg) { + const socksPortFlags = Services.prefs.getCharPref( + "extensions.torlauncher.socks_port_flags", + "IPv6Traffic PreferIPv6 KeepAliveIsolateSOCKSAuth" + ); + if (socksPortFlags) { + socksPortArg += " " + socksPortFlags; + } + this.#args.push("+__SocksPort", socksPortArg); + } + } + + /** + * Hash a password to then pass it to Tor as a command line argument. + * Based on Vidalia's TorSettings::hashPassword(). + * + * @param {Uint8Array} password The password, as an array of bytes + * @returns {string} The hashed password + */ + #hashPassword(password) { + // The password has already been checked by the caller. + + // Generate a random, 8 byte salt value. + const salt = Array.from(crypto.getRandomValues(new Uint8Array(8))); + + // Run through the S2K algorithm and convert to a string. + const toHex = v => v.toString(16).padStart(2, "0"); + const arrayToHex = aArray => aArray.map(toHex).join(""); + const kCodedCount = 96; + const hashVal = this.#cryptoSecretToKey( + Array.from(password), + salt, + kCodedCount + ); + return "16:" + arrayToHex(salt) + toHex(kCodedCount) + arrayToHex(hashVal); + } + + /** + * Generates and return a hash of a password by following the iterated and + * salted S2K algorithm (see RFC 2440 section 3.6.1.3). + * See also https://gitlab.torproject.org/tpo/core/torspec/-/blob/main/control-spec.txt#L3824. + * #cryptoSecretToKey() is similar to Vidalia's crypto_secret_to_key(). + * + * @param {Array} password The password to hash, as an array of bytes + * @param {Array} salt The salt to use for the hash, as an array of bytes + * @param {number} codedCount The counter, coded as specified in RFC 2440 + * @returns {Array} The hash of the password, as an array of bytes + */ + #cryptoSecretToKey(password, salt, codedCount) { + const inputArray = salt.concat(password); + + // Subtle crypto only has the final digest, and does not allow incremental + // updates. + const hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(hasher.SHA1); + const kEXPBIAS = 6; + let count = (16 + (codedCount & 15)) << ((codedCount >> 4) + kEXPBIAS); + while (count > 0) { + if (count > inputArray.length) { + hasher.update(inputArray, inputArray.length); + count -= inputArray.length; + } else { + const finalArray = inputArray.slice(0, count); + hasher.update(finalArray, finalArray.length); + count = 0; + } + } + return hasher + .finish(false) + .split("") + .map(b => b.charCodeAt(0)); + } +} diff --git a/toolkit/components/tor-launcher/TorProcessAndroid.sys.mjs b/toolkit/components/tor-launcher/TorProcessAndroid.sys.mjs @@ -0,0 +1,131 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", +}); + +const logger = console.createInstance({ + maxLogLevel: "Info", + prefix: "TorProcessAndroid", +}); + +const TorOutgoingEvents = Object.freeze({ + start: "GeckoView:Tor:StartTor", + stop: "GeckoView:Tor:StopTor", +}); + +// The events we will listen to +const TorIncomingEvents = Object.freeze({ + started: "GeckoView:Tor:TorStarted", + startFailed: "GeckoView:Tor:TorStartFailed", + exited: "GeckoView:Tor:TorExited", +}); + +/** + * This class allows to start a tor process on Android devices. + * + * GeckoView does not include the facilities to start processes from JavaScript, + * therefore the actual implementation is written in Java, and this is just + * plumbing code over the global EventDispatcher. + */ +export class TorProcessAndroid { + /** + * The handle the Java counterpart uses to refer to the process we started. + * We use it to filter the exit events and make sure they refer to the daemon + * we are interested in. + */ + #processHandle = null; + /** + * The promise resolver we call when the Java counterpart sends the event that + * tor has started. + */ + #startResolve = null; + /** + * The promise resolver we call when the Java counterpart sends the event that + * it failed to start tor. + */ + #startReject = null; + + onExit = () => {}; + + get isRunning() { + return !!this.#processHandle; + } + + async start() { + // Generate the handle on the JS side so that it's ready in case it takes + // less to start the process than to propagate the success. + this.#processHandle = crypto.randomUUID(); + logger.info(`Starting new process with handle ${this.#processHandle}`); + // Let's declare it immediately, so that the Java side can do its stuff in + // an async manner and we avoid possible race conditions (at most we await + // an already resolved/rejected promise. + const startEventPromise = new Promise((resolve, reject) => { + this.#startResolve = resolve; + this.#startReject = reject; + }); + lazy.EventDispatcher.instance.registerListener( + this, + Object.values(TorIncomingEvents) + ); + let config; + try { + config = await lazy.EventDispatcher.instance.sendRequestForResult({ + type: TorOutgoingEvents.start, + handle: this.#processHandle, + tcpSocks: Services.prefs.getBoolPref( + "extensions.torlauncher.socks_port_use_tcp", + false + ), + }); + logger.debug("Sent the start event."); + } catch (e) { + this.forget(); + throw e; + } + await startEventPromise; + return config; + } + + forget() { + // Processes usually exit when we close the control port connection to them. + logger.trace(`Forgetting process ${this.#processHandle}`); + lazy.EventDispatcher.instance.sendRequestForResult({ + type: TorOutgoingEvents.stop, + handle: this.#processHandle, + }); + logger.debug("Sent the start event."); + this.#processHandle = null; + lazy.EventDispatcher.instance.unregisterListener( + this, + Object.values(TorIncomingEvents) + ); + } + + onEvent(event, data, _callback) { + if (data?.handle !== this.#processHandle) { + logger.debug(`Ignoring event ${event} with another handle`, data); + return; + } + logger.info(`Received an event ${event}`, data); + switch (event) { + case TorIncomingEvents.started: + this.#startResolve(); + break; + case TorIncomingEvents.startFailed: + this.#startReject(new Error(data.error)); + break; + case TorIncomingEvents.exited: + this.forget(); + if (this.#startReject !== null) { + this.#startReject(); + } + this.onExit(data.status); + break; + } + } +} diff --git a/toolkit/components/tor-launcher/TorProvider.sys.mjs b/toolkit/components/tor-launcher/TorProvider.sys.mjs @@ -0,0 +1,1148 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +import { TorLauncherUtil } from "resource://gre/modules/TorLauncherUtil.sys.mjs"; +import { TorParsers } from "resource://gre/modules/TorParsers.sys.mjs"; +import { + TorBootstrapError, + TorProviderTopics, +} from "resource://gre/modules/TorProviderBuilder.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + TorController: "resource://gre/modules/TorControlPort.sys.mjs", + TorProcess: "resource://gre/modules/TorProcess.sys.mjs", + TorProcessAndroid: "resource://gre/modules/TorProcessAndroid.sys.mjs", + TorProxyType: "resource://gre/modules/TorSettings.sys.mjs", + TorSettings: "resource://gre/modules/TorSettings.sys.mjs", +}); + +const logger = console.createInstance({ + maxLogLevelPref: "browser.tor_provider.log_level", + prefix: "TorProvider", +}); + +/** + * @typedef {object} ControlPortSettings An object with the settings to use for + * the control port. All the entries are optional, but an authentication + * mechanism and a communication method must be specified. + * @property {Uint8Array} [password] The clear text password as an array of + * bytes. It must always be defined, unless cookieFilePath is + * @property {string} [cookieFilePath] The path to the cookie file to use for + * authentication + * @property {nsIFile} [ipcFile] The nsIFile object with the path to a Unix + * socket to use for control socket + * @property {string} [host] The host to connect for a TCP control port + * @property {number} [port] The port number to use for a TCP control port + */ +/** + * @typedef {object} SocksSettings An object that includes the proxy settings to + * be configured in the browser. + * @property {boolean} [transproxy] If true, no proxy is configured + * @property {nsIFile} [ipcFile] The nsIFile object with the path to a Unix + * socket to use for an IPC proxy + * @property {string} [host] The host to connect for a TCP proxy + * @property {number} [port] The port number to use for a TCP proxy + */ +/** + * Stores the data associated with a circuit node. + * + * @typedef NodeData + * @property {NodeFingerprint} fingerprint The node fingerprint + * @property {string[]} ipAddrs The ip addresses associated with this node + * @property {string?} bridgeType The bridge type for this node, or "" if the + * node is a bridge but the type is unknown, or null if this is not a bridge + * node + * @property {string?} regionCode An upper case 2-letter ISO3166-1 code for the + * first ip address, or null if there is no region. This should also be a + * valid BCP47 Region subtag + */ + +const Preferences = Object.freeze({ + ControlUseIpc: "extensions.torlauncher.control_port_use_ipc", + ControlHost: "extensions.torlauncher.control_host", + ControlPort: "extensions.torlauncher.control_port", +}); + +/* Config Keys used to configure tor daemon */ +const TorConfigKeys = Object.freeze({ + useBridges: "UseBridges", + bridgeList: "Bridge", + socks4Proxy: "Socks4Proxy", + socks5Proxy: "Socks5Proxy", + socks5ProxyUsername: "Socks5ProxyUsername", + socks5ProxyPassword: "Socks5ProxyPassword", + httpsProxy: "HTTPSProxy", + httpsProxyAuthenticator: "HTTPSProxyAuthenticator", + reachableAddresses: "ReachableAddresses", + clientTransportPlugin: "ClientTransportPlugin", +}); + +/** + * This is a Tor provider for the C Tor daemon. + * + * It can start a new tor instance, or connect to an existing one. + * In the former case, it also takes its ownership by default. + */ +export class TorProvider { + /** + * The control port settings. + * + * @type {ControlPortSettings?} + */ + #controlPortSettings = null; + /** + * An instance of the tor controller. + * We take for granted that if it is not null, we connected to it and managed + * to authenticate. + * Public methods can use the #controller getter, which will throw an + * exception whenever the control port is not open. + * + * @type {TorController?} + */ + #controlConnection = null; + /** + * A helper that can be used to get the control port connection and assert it + * is open and it can be used. + * If this is not the case, this getter will throw. + * + * @returns {TorController} + */ + get #controller() { + if (!this.#controlConnection?.isOpen) { + throw new Error("Control port connection not available."); + } + return this.#controlConnection; + } + /** + * A function that can be called to cancel the current connection attempt. + */ + #cancelConnection = () => {}; + + /** + * The tor process we launched. + * + * @type {TorProcess} + */ + #torProcess = null; + + /** + * The settings for the SOCKS proxy. + * + * @type {SocksSettings?} + */ + #socksSettings = null; + + #isBootstrapDone = false; + /** + * Keep the last warning to avoid broadcasting an async warning if it is the + * same one as the last broadcast. + */ + #lastWarning = {}; + + /** + * Stores the nodes of a circuit. Keys are cicuit IDs, and values are the node + * fingerprints. + * + * Theoretically, we could hook this map up to the new identity notification, + * but in practice it does not work. Tor pre-builds circuits, and the NEWNYM + * signal does not affect them. So, we might end up using a circuit that was + * built before the new identity but not yet used. If we cleaned the map, we + * risked of not having the data about it. + * + * @type {Map<CircuitID, Promise<NodeFingerprint[]>>} + */ + #circuits = new Map(); + /** + * The last used bridge, or null if bridges are not in use or if it was not + * possible to detect the bridge. This needs the user to have specified bridge + * lines with fingerprints to work. + * + * @type {NodeFingerprint?} + */ + #currentBridge = null; + + /** + * Starts a new tor process and connect to its control port, or connect to the + * control port of an existing tor daemon. + */ + async init() { + logger.debug("Initializing the Tor provider."); + + // These settings might be customized in the following steps. + if (TorLauncherUtil.isAndroid) { + this.#socksSettings = { transproxy: false }; + } else { + this.#socksSettings = TorLauncherUtil.getPreferredSocksConfiguration(); + logger.debug("Requested SOCKS configuration", this.#socksSettings); + } + + try { + await this.#setControlPortConfiguration(); + } catch (e) { + logger.error("We do not have a control port configuration", e); + throw e; + } + + if (this.#socksSettings.transproxy) { + logger.info("Transparent proxy required, not starting a Tor daemon."); + } else if (this.ownsTorDaemon) { + try { + await this.#startDaemon(); + } catch (e) { + logger.error("Failed to start the tor daemon", e); + throw e; + } + } else { + logger.debug( + "Not starting a tor daemon because we were requested not to." + ); + } + + try { + await this.#firstConnection(); + } catch (e) { + logger.error("Cannot connect to the control port", e); + throw e; + } + + if (this.ownsTorDaemon) { + try { + await lazy.TorSettings.initializedPromise; + await lazy.TorSettings.setTorProvider(this); + } catch (e) { + logger.warn( + "Failed to initialize TorSettings or to write our initial settings. Continuing the initialization anyway.", + e + ); + } + } + + TorLauncherUtil.setProxyConfiguration(this.#socksSettings); + + logger.info("The Tor provider is ready."); + + // If we are using an external Tor daemon, we might need to fetch circuits + // already, in case streams use them. Do not await because we do not want to + // block the intialization on this (it should not fail anyway...). + this.#fetchCircuits(); + } + + /** + * Close the connection to the tor daemon. + * When Tor is started by Tor Browser, it is configured to exit when the + * control connection is closed. Therefore, as a matter of facts, calling this + * function also makes the child Tor instance stop. + */ + uninit() { + logger.debug("Uninitializing the Tor provider."); + + if (this.#torProcess) { + this.#torProcess.forget(); + this.#torProcess.onExit = () => {}; + this.#torProcess = null; + } + + this.#closeConnection("Uninitializing the provider."); + } + + // Provider API + + /** + * Send settings to the tor daemon. + * + * @param {Map<string, ?string>} torSettings - The key value pairs to pass in. + */ + async #writeSettings(torSettings) { + logger.debug("Mapped settings object", torSettings); + + // NOTE: The order in which TorProvider.#writeSettings should match the + // order in which the configuration is passed onto setConf. In turn, + // TorControlPort.setConf should similarly ensure that the configuration + // reaches the tor process in the same order. + // In particular, we do not want a race where an earlier call to + // TorProvider.#writeSettings for overlapping settings can be delayed and + // override a later call. + await this.#controller.setConf(Array.from(torSettings)); + } + + /** + * Send bridge settings to the tor daemon. + * + * This should only be called by the `TorSettings` module. + * + * @param {TorBridgeSettings} bridges - The bridge settings to apply. + */ + async writeBridgeSettings(bridges) { + logger.debug("TorProvider.writeBridgeSettings", bridges); + const torSettings = new Map(); + + // Bridges + const haveBridges = bridges?.enabled && !!bridges.bridge_strings.length; + torSettings.set(TorConfigKeys.useBridges, haveBridges); + torSettings.set( + TorConfigKeys.bridgeList, + haveBridges ? bridges.bridge_strings : null + ); + + await this.#writeSettings(torSettings); + } + + /** + * Send proxy settings to the tor daemon. + * + * This should only be called by the `TorSettings` module. + * + * @param {TorProxySettings} proxy - The proxy settings to apply. + */ + async writeProxySettings(proxy) { + logger.debug("TorProvider.writeProxySettings", proxy); + const torSettings = new Map(); + + torSettings.set(TorConfigKeys.socks4Proxy, null); + torSettings.set(TorConfigKeys.socks5Proxy, null); + torSettings.set(TorConfigKeys.socks5ProxyUsername, null); + torSettings.set(TorConfigKeys.socks5ProxyPassword, null); + torSettings.set(TorConfigKeys.httpsProxy, null); + torSettings.set(TorConfigKeys.httpsProxyAuthenticator, null); + + const type = proxy.enabled ? proxy.type : null; + const { address, port, username, password } = proxy; + + switch (type) { + case lazy.TorProxyType.Socks4: + torSettings.set(TorConfigKeys.socks4Proxy, `${address}:${port}`); + break; + case lazy.TorProxyType.Socks5: + torSettings.set(TorConfigKeys.socks5Proxy, `${address}:${port}`); + torSettings.set(TorConfigKeys.socks5ProxyUsername, username); + torSettings.set(TorConfigKeys.socks5ProxyPassword, password); + break; + case lazy.TorProxyType.HTTPS: + torSettings.set(TorConfigKeys.httpsProxy, `${address}:${port}`); + torSettings.set( + TorConfigKeys.httpsProxyAuthenticator, + `${username}:${password}` + ); + break; + } + + await this.#writeSettings(torSettings); + } + + /** + * Send firewall settings to the tor daemon. + * + * This should only be called by the `TorSettings` module. + * + * @param {TorFirewallSettings} firewall - The firewall settings to apply. + */ + async writeFirewallSettings(firewall) { + logger.debug("TorProvider.writeFirewallSettings", firewall); + const torSettings = new Map(); + + torSettings.set( + TorConfigKeys.reachableAddresses, + firewall.enabled + ? firewall.allowed_ports.map(port => `*:${port}`).join(",") + : null + ); + + await this.#writeSettings(torSettings); + } + + async flushSettings() { + if (TorLauncherUtil.isAndroid) { + // Android does not have a torrc to flush to. See tor-browser#43577. + return; + } + await this.#controller.flushSettings(); + } + + /** + * Start the bootstrap process. + */ + async connect() { + await this.#controller.setNetworkEnabled(true); + if (this.#socksSettings.port === 0) { + // Enablign/disabling network resets also the SOCKS listener. + // So, every time we do it, we need to update the browser's configuration + // to use the updated port. + const settings = structuredClone(this.#socksSettings); + for (const listener of await this.#controller.getSocksListeners()) { + // When set to automatic port, ignore any IPC listener, as the intention + // was to use TCP. + if (listener.ipcPath) { + continue; + } + // The tor daemon can have any number of SOCKS listeners (see SocksPort + // in man 1 tor). We take for granted that any TCP one will work for us. + settings.host = listener.host; + settings.port = listener.port; + break; + } + TorLauncherUtil.setProxyConfiguration(settings); + } + this.#lastWarning = {}; + this.retrieveBootstrapStatus(); + } + + /** + * Stop the bootstrap process. + */ + async stopBootstrap() { + // Tell tor to disable use of the network; this should stop the bootstrap. + await this.#controller.setNetworkEnabled(false); + // We are not interested in waiting for this, nor in **catching its error**, + // so we do not await this. We just want to be notified when the bootstrap + // status is actually updated through observers. + this.retrieveBootstrapStatus(); + } + + /** + * Ask Tor to swtich to new circuits and clear the DNS cache. + */ + async newnym() { + await this.#controller.newnym(); + } + + /** + * Get the bridges Tor has been configured with. + * + * @returns {Bridge[]} The configured bridges + */ + async getBridges() { + // Ideally, we would not need this function, because we should be the one + // setting them with TorSettings. However, TorSettings is not notified of + // change of settings. So, asking tor directly with the control connection + // is the most reliable way of getting the configured bridges, at the + // moment. Also, we are using this for the circuit display, which should + // work also when we are not configuring the tor daemon, but just using it. + return this.#controller.getBridges(); + } + + /** + * Get the configured pluggable transports. + * + * @returns {PTInfo[]} An array with the info of all the configured pluggable + * transports. + */ + async getPluggableTransports() { + return this.#controller.getPluggableTransports(); + } + + /** + * Ask Tor its bootstrap phase. + * This function will also update the internal state when using an external + * tor daemon. + * + * @returns {object} An object with the bootstrap information received from + * Tor. Its keys might vary, depending on the input + */ + async retrieveBootstrapStatus() { + this.#processBootstrapStatus( + await this.#controller.getBootstrapPhase(), + false + ); + } + + /** + * Returns tha data about a relay or a bridge. + * + * @param {string} id The fingerprint of the node to get data about + * @returns {Promise<NodeData>} + */ + async getNodeInfo(id) { + const node = { + fingerprint: id, + ipAddrs: [], + bridgeType: null, + regionCode: null, + }; + const bridge = (await this.#controller.getBridges())?.find( + foundBridge => foundBridge.id?.toUpperCase() === id.toUpperCase() + ); + if (bridge) { + node.bridgeType = bridge.transport ?? ""; + // Attempt to get an IP address from bridge address string. + const ip = bridge.addr.match(/^\[?([^\]]+)\]?:\d+$/)?.[1]; + if (ip && !ip.startsWith("0.")) { + node.ipAddrs.push(ip); + } + } else { + node.ipAddrs = await this.#controller.getNodeAddresses(id); + } + // tor-browser#43116, tor-browser-build#41224: on Android, we do not ship + // the GeoIP databases to save some space. So skip it for now. + if (node.ipAddrs.length && !TorLauncherUtil.isAndroid) { + // Get the country code for the node's IP address. + try { + // Expect a 2-letter ISO3166-1 code, which should also be a valid + // BCP47 Region subtag. + const regionCode = await this.#controller.getIPCountry(node.ipAddrs[0]); + if (regionCode && regionCode !== "??") { + node.regionCode = regionCode.toUpperCase(); + } + } catch (e) { + logger.warn(`Cannot get a country for IP ${node.ipAddrs[0]}`, e); + } + } + return node; + } + + /** + * Add a private key to the Tor configuration. + * + * @param {string} address The address of the onion service + * @param {string} b64PrivateKey The private key of the service, in base64 + * @param {boolean} isPermanent Tell whether the key should be saved forever + */ + async onionAuthAdd(address, b64PrivateKey, isPermanent) { + await this.#controller.onionAuthAdd(address, b64PrivateKey, isPermanent); + } + + /** + * Remove a private key from the Tor configuration. + * + * @param {string} address The address of the onion service + */ + async onionAuthRemove(address) { + await this.#controller.onionAuthRemove(address); + } + + /** + * Retrieve the list of private keys. + * + * @returns {OnionAuthKeyInfo[]} The onion authentication keys known by the + * tor daemon + */ + async onionAuthViewKeys() { + return this.#controller.onionAuthViewKeys(); + } + + /** + * @returns {boolean} true if we launched and control tor, false if we are + * using system tor. + */ + get ownsTorDaemon() { + return TorLauncherUtil.shouldStartAndOwnTor; + } + + get isBootstrapDone() { + return this.#isBootstrapDone; + } + + /** + * TODO: Rename to isReady once we remove finish the migration. + * + * @returns {boolean} true if we currently have a connection to the control + * port. We take for granted that if we have one, we authenticated to it, and + * so we have already verified we can send and receive data. + */ + get isRunning() { + return this.#controlConnection?.isOpen ?? false; + } + + /** + * Return the data about the current bridge, if any, or null. + * We can detect bridge only when the configured bridge lines include the + * fingerprints. + * + * @returns {NodeData?} The node information, or null if the first node + * is not a bridge, or no circuit has been opened, yet. + */ + get currentBridge() { + return this.#currentBridge; + } + + // Process management + + async #startDaemon() { + // TorProcess should be instanced once, then always reused and restarted + // only through the prompt it exposes when the controlled process dies. + if (this.#torProcess) { + logger.warn( + "Ignoring a request to start a tor daemon because one is already running." + ); + return; + } + + if (TorLauncherUtil.isAndroid) { + this.#torProcess = new lazy.TorProcessAndroid(); + } else { + this.#torProcess = new lazy.TorProcess( + this.#controlPortSettings, + this.#socksSettings + ); + } + // Use a closure instead of bind because we reassign #cancelConnection. + // Also, we now assign an exit handler that cancels the first connection, + // so that a sudden exit before the first connection is completed might + // still be handled as an initialization failure. + // But after the first connection is created successfully, we will change + // the exit handler to broadcast a notification instead. + this.#torProcess.onExit = () => { + this.#cancelConnection( + "The tor process exited before the first connection" + ); + }; + + logger.debug("Trying to start the tor process."); + const res = await this.#torProcess.start(); + if (TorLauncherUtil.isAndroid) { + logger.debug("Configuration from TorProcessAndriod", res); + this.#controlPortSettings = { + ipcFile: new lazy.FileUtils.File(res.controlPortPath), + cookieFilePath: res.cookieFilePath, + }; + this.#socksSettings = { + transproxy: false, + }; + if (res.socksPath) { + this.#socksSettings.ipcFile = new lazy.FileUtils.File(res.socksPath); + } else if (res.socksPort !== undefined) { + this.#socksSettings.host = res.socksHost ?? "127.0.0.1"; + this.#socksSettings.port = res.socksPort; + } else { + throw new Error( + "TorProcessAndroid did not return a valid SOCKS configuration." + ); + } + } + logger.info("Started a tor process"); + } + + // Control port setup and connection + + /** + * Read the control port settings from environment variables and from + * preferences. + */ + async #setControlPortConfiguration() { + logger.debug("Reading the control port configuration"); + const settings = {}; + + if (TorLauncherUtil.isAndroid) { + // We will populate the settings after having started the daemon. + return; + } + + const isWindows = Services.appinfo.OS === "WINNT"; + // Determine how Tor Launcher will connect to the Tor control port. + // Environment variables get top priority followed by preferences. + if (!isWindows && Services.env.exists("TOR_CONTROL_IPC_PATH")) { + const ipcPath = Services.env.get("TOR_CONTROL_IPC_PATH"); + settings.ipcFile = new lazy.FileUtils.File(ipcPath); + } else { + // Check for TCP host and port environment variables. + if (Services.env.exists("TOR_CONTROL_HOST")) { + settings.host = Services.env.get("TOR_CONTROL_HOST"); + } + if (Services.env.exists("TOR_CONTROL_PORT")) { + const port = parseInt(Services.env.get("TOR_CONTROL_PORT"), 10); + if (Number.isInteger(port) && port > 0 && port <= 65535) { + settings.port = port; + } + } + } + + const useIPC = + !isWindows && + Services.prefs.getBoolPref(Preferences.ControlUseIpc, false); + if (!settings.host && !settings.port && useIPC) { + settings.ipcFile = TorLauncherUtil.getTorFile("control_ipc", false); + } else { + if (!settings.host) { + settings.host = Services.prefs.getCharPref( + Preferences.ControlHost, + "127.0.0.1" + ); + } + if (!settings.port) { + settings.port = Services.prefs.getIntPref( + Preferences.ControlPort, + 9151 + ); + } + } + + if (Services.env.exists("TOR_CONTROL_PASSWD")) { + const password = Services.env.get("TOR_CONTROL_PASSWD"); + // As per 3.5 of control-spec.txt, AUTHENTICATE can use either a quoted + // string, or a sequence of hex characters. + // However, the password is hashed byte by byte, so we need to convert the + // string to its character codes, or the hex digits to actual bytes. + // Notice that Tor requires at least one hex character, without an upper + // limit, but it does not explicitly tell how to pad an odd number of hex + // characters, so we require the user to hand an even number of hex + // digits. + // We also want to enforce the authentication if we start the daemon. + // So, if a password is not valid (not a hex sequence and not a quoted + // string), or if it is empty (including the quoted empty string), we + // force a random password. + if ( + password.length >= 2 && + password[0] === '"' && + password[password.length - 1] === '"' + ) { + const encoder = new TextEncoder(); + settings.password = encoder.encode(TorParsers.unescapeString(password)); + } else if (/^([0-9a-fA-F]{2})+$/.test(password)) { + settings.password = new Uint8Array(password.length / 2); + for (let i = 0, j = 0; i < settings.password.length; i++, j += 2) { + settings.password[i] = parseInt(password.substring(j, j + 2), 16); + } + } + if (password && !settings.password?.length) { + logger.warn( + "Invalid password specified at TOR_CONTROL_PASSWD. " + + "You should put it in double quotes, or it should be a hex-encoded sequence. " + + "The password cannot be empty. " + + "A random password will be used, instead." + ); + } + } else if (Services.env.exists("TOR_CONTROL_COOKIE_AUTH_FILE")) { + const cookiePath = Services.env.get("TOR_CONTROL_COOKIE_AUTH_FILE"); + if (cookiePath) { + settings.cookieFilePath = cookiePath; + } + } + if ( + this.ownsTorDaemon && + !settings.password?.length && + !settings.cookieFilePath + ) { + settings.password = this.#generateRandomPassword(); + } + this.#controlPortSettings = settings; + logger.debug("Control port configuration read"); + } + + /** + * Start the first connection to the Tor daemon. + * This function should be called only once during the initialization. + */ + async #firstConnection() { + let canceled = false; + let timeout = 0; + const maxDelay = 10_000; + let delay = 5; + logger.debug("Connecting to the control port for the first time."); + this.#controlConnection = await new Promise((resolve, reject) => { + this.#cancelConnection = reason => { + canceled = true; + clearTimeout(timeout); + reject(new Error(reason)); + }; + const tryConnect = () => { + if (this.ownsTorDaemon && !this.#torProcess?.isRunning) { + reject(new Error("The controlled tor daemon is not running.")); + return; + } + this.#openControlPort() + .then(controller => { + this.#cancelConnection = () => {}; + // The cancel function should have already called reject. + if (!canceled) { + logger.info("Connected to the control port."); + resolve(controller); + } + }) + .catch(e => { + if (delay < maxDelay && !canceled) { + logger.info( + `Failed to connect to the control port. Trying again in ${delay}ms.`, + e + ); + timeout = setTimeout(tryConnect, delay); + delay *= 2; + } else { + reject(e); + } + }); + }; + tryConnect(); + }); + + // The following code will never throw, but we still want to wait for it + // before marking the provider as initialized. + + if (this.ownsTorDaemon) { + // The first connection cannot be canceled anymore, and the rest of the + // code is supposed not to fail. If the tor process exits, from now on we + // can only close the connection and broadcast a notification. + this.#torProcess.onExit = exitCode => { + logger.info(`The tor process exited with code ${exitCode}`); + this.#closeConnection("The tor process exited suddenly"); + Services.obs.notifyObservers(null, TorProviderTopics.ProcessExited); + }; + if (!TorLauncherUtil.shouldOnlyConfigureTor) { + await this.#takeOwnership(); + } + } + await this.#setupEvents(); + } + + /** + * Try to become the primary controller. This will make tor exit when our + * connection is closed. + * This function cannot fail or throw (any exception will be treated as a + * warning and just logged). + */ + async #takeOwnership() { + logger.debug("Taking the ownership of the tor process."); + try { + await this.#controlConnection.takeOwnership(); + } catch (e) { + logger.warn("Take ownership failed", e); + return; + } + try { + await this.#controlConnection.resetOwningControllerProcess(); + } catch (e) { + logger.warn("Clear owning controller process failed", e); + } + } + + /** + * Tells the Tor daemon which events we want to receive. + * This function will never throw. Any failure will be treated as a warning of + * a possibly degraded experience, not as an error. + */ + async #setupEvents() { + // We always listen to these events, because they are needed for the circuit + // display. + const events = ["CIRC", "STREAM"]; + if (this.ownsTorDaemon) { + events.push("STATUS_CLIENT", "NOTICE", "WARN", "ERR"); + // Do not await on the first bootstrap status retrieval, and do not + // propagate its errors. + this.#controlConnection + .getBootstrapPhase() + .then(status => this.#processBootstrapStatus(status, false)) + .catch(e => + logger.error("Failed to get the first bootstrap status", e) + ); + } + try { + logger.debug(`Setting events: ${events.join(" ")}`); + await this.#controlConnection.setEvents(events); + } catch (e) { + logger.error( + "We could not enable all the events we need. Tor Browser's functionalities might be reduced.", + e + ); + } + } + + /** + * Open a connection to the control port and authenticate to it. + * #setControlPortConfiguration must have been called before, as this function + * will follow the configuration set by it. + * + * @returns {Promise<TorController>} An authenticated TorController + */ + async #openControlPort() { + let controlPort; + if (this.#controlPortSettings.ipcFile) { + controlPort = lazy.TorController.fromIpcFile( + this.#controlPortSettings.ipcFile, + this + ); + } else { + controlPort = lazy.TorController.fromSocketAddress( + this.#controlPortSettings.host, + this.#controlPortSettings.port, + this + ); + } + try { + let password = this.#controlPortSettings.password; + if (password === undefined && this.#controlPortSettings.cookieFilePath) { + password = await this.#readAuthenticationCookie( + this.#controlPortSettings.cookieFilePath + ); + } + await controlPort.authenticate(password); + } catch (e) { + try { + controlPort.close(); + } catch (ec) { + // Tor already closes the control port when the authentication fails. + logger.debug( + "Expected exception when closing the control port for a failed authentication", + ec + ); + } + throw e; + } + return controlPort; + } + + /** + * Close the connection to the control port. + * + * @param {string} reason The reason for which we are closing the connection + * (used for logging and in case this ends up canceling the current connection + * attempt) + */ + #closeConnection(reason) { + this.#cancelConnection(reason); + if (this.#controlConnection) { + logger.info("Closing the control connection", reason); + try { + this.#controlConnection.close(); + } catch (e) { + logger.error("Failed to close the control port connection", e); + } + this.#controlConnection = null; + } else { + logger.trace( + "Requested to close an already closed control port connection" + ); + } + this.#isBootstrapDone = false; + this.#lastWarning = {}; + } + + // Authentication + + /** + * Read a cookie file to perform cookie-based authentication. + * + * @param {string} path The path to the cookie file + * @returns {Uint8Array} The content of the file in bytes + */ + async #readAuthenticationCookie(path) { + return IOUtils.read(path); + } + + /** + * @returns {Uint8Array} A random 16-byte password. + */ + #generateRandomPassword() { + const kPasswordLen = 16; + return crypto.getRandomValues(new Uint8Array(kPasswordLen)); + } + + /** + * Ask Tor the circuits it already knows to populate our circuit map with the + * circuits that were already open before we started listening for events. + */ + async #fetchCircuits() { + for (const { id, nodes } of await this.#controller.getCircuits()) { + this.onCircuitBuilt(id, nodes); + } + } + + // Notification handlers + + /** + * Receive and process a notification with the bootstrap status. + * + * @param {object} status The status object + */ + onBootstrapStatus(status) { + logger.debug("Received bootstrap status update", status); + this.#processBootstrapStatus(status, true); + } + + /** + * Process a bootstrap status to update the current state, and broadcast it + * to TorBootstrapStatus observers. + * + * @param {object} statusObj The status object that the controller returned. + * Its entries depend on what Tor sent to us. + * @param {boolean} isNotification We broadcast warnings only when we receive + * them through an asynchronous notification. + */ + #processBootstrapStatus(statusObj, isNotification) { + // Notify observers + Services.obs.notifyObservers(statusObj, TorProviderTopics.BootstrapStatus); + + if (statusObj.PROGRESS === 100) { + this.#isBootstrapDone = true; + return; + } + + this.#isBootstrapDone = false; + + // Can TYPE ever be ERR for STATUS_CLIENT? + if ( + isNotification && + statusObj.TYPE === "WARN" && + statusObj.RECOMMENDATION !== "ignore" + ) { + this.#notifyBootstrapError(statusObj); + } + } + + /** + * Broadcast a bootstrap warning or error. + * + * @param {object} statusObj The bootstrap status object with the error + */ + #notifyBootstrapError(statusObj) { + logger.error("Tor bootstrap error", statusObj); + + if ( + statusObj.TAG !== this.#lastWarning.phase || + statusObj.REASON !== this.#lastWarning.reason + ) { + this.#lastWarning.phase = statusObj.TAG; + this.#lastWarning.reason = statusObj.REASON; + + // FIXME: currently, this is observed only by TorBoostrapRequest. + // We should remove that class, and use an async method to do the + // bootstrap here. + // At that point, the lastWarning mechanism will probably not be necessary + // anymore, since the first error eligible for notification will as a + // matter of fact cancel the bootstrap. + Services.obs.notifyObservers( + new TorBootstrapError({ + phase: statusObj.TAG, + reason: statusObj.REASON, + summary: statusObj.SUMMARY, + }), + TorProviderTopics.BootstrapError + ); + } + } + + /** + * Handle a log message from the tor daemon. It will be added to the internal + * logs. If it is a warning or an error, a notification will be broadcast. + * + * @param {string} type The message type + * @param {string} msg The message + */ + onLogMessage(type, msg) { + if (type === "WARN" || type === "ERR") { + // Notify so that Copy Log can be enabled. + Services.obs.notifyObservers(null, TorProviderTopics.HasWarnOrErr); + } + + const timestamp = new Date() + .toISOString() + .replace("T", " ") + .replace("Z", ""); + + Services.obs.notifyObservers( + { type, msg, timestamp }, + TorProviderTopics.TorLog + ); + + switch (type) { + case "ERR": + logger.error(`[Tor error] ${msg}`); + break; + case "WARN": + logger.warn(`[Tor warning] ${msg}`); + break; + default: + logger.info(`[Tor ${type.toLowerCase()}] ${msg}`); + } + } + + /** + * Handle a notification that a new circuit has been built. + * If a change of bridge is detected (including a change from bridge to a + * normal guard), a notification is broadcast. + * + * @param {CircuitID} id The circuit ID + * @param {NodeFingerprint[]} nodes The nodes that compose the circuit + */ + async onCircuitBuilt(id, nodes) { + this.#circuits.set(id, nodes); + logger.debug(`Built tor circuit ${id}`, nodes); + // Ignore circuits of length 1, that are used, for example, to probe + // bridges. So, only store them, since we might see streams that use them, + // but then early-return. + if (nodes.length === 1) { + return; + } + + if (this.#currentBridge?.fingerprint !== nodes[0]) { + const nodeInfo = await this.getNodeInfo(nodes[0]); + let notify = false; + if (nodeInfo?.bridgeType) { + logger.info(`Bridge changed to ${nodes[0]}`); + this.#currentBridge = nodeInfo; + notify = true; + } else if (this.#currentBridge) { + logger.info("Bridges disabled"); + this.#currentBridge = null; + notify = true; + } + if (notify) { + Services.obs.notifyObservers(null, TorProviderTopics.BridgeChanged); + } + } + } + + /** + * Handle a notification of a circuit being closed. We use it to clean the + * internal data. + * + * @param {CircuitID} id The circuit id + */ + onCircuitClosed(id) { + logger.debug("Circuit closed event", id); + this.#circuits.delete(id); + } + + /** + * Handle a notification about a stream switching to the sentconnect status. + * + * @param {StreamID} streamId The ID of the stream that switched to the + * sentconnect status. + * @param {CircuitID} circuitId The ID of the circuit used by the stream + * @param {string} username The SOCKS username + * @param {string} password The SOCKS password + */ + async onStreamSentConnect(streamId, circuitId, username, password) { + if (!username || !password) { + return; + } + logger.debug("Stream sentconnect event", username, password, circuitId); + let circuit = this.#circuits.get(circuitId); + if (!circuit) { + circuit = new Promise((resolve, reject) => { + this.#controlConnection.getCircuits().then(circuits => { + for (const { id, nodes } of circuits) { + if (id === circuitId) { + resolve(nodes); + return; + } + // Opportunistically collect circuits, since we are iterating them. + this.#circuits.set(id, nodes); + } + logger.error( + `Seen a STREAM SENTCONNECT with circuit ${circuitId}, but Tor did not send information about it.` + ); + reject(); + }); + }); + this.#circuits.set(circuitId, circuit); + } + try { + circuit = await circuit; + } catch { + return; + } + Services.obs.notifyObservers( + { + wrappedJSObject: { + username, + password, + circuit, + }, + }, + TorProviderTopics.CircuitCredentialsMatched + ); + } +} diff --git a/toolkit/components/tor-launcher/TorProviderBuilder.sys.mjs b/toolkit/components/tor-launcher/TorProviderBuilder.sys.mjs @@ -0,0 +1,338 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + TorLauncherUtil: "resource://gre/modules/TorLauncherUtil.sys.mjs", + TorProvider: "resource://gre/modules/TorProvider.sys.mjs", +}); + +export const TorProviderTopics = Object.freeze({ + ProcessExited: "TorProcessExited", + BootstrapStatus: "TorBootstrapStatus", + BootstrapError: "TorBootstrapError", + TorLog: "TorLog", + HasWarnOrErr: "TorLogHasWarnOrErr", + BridgeChanged: "TorBridgeChanged", + CircuitCredentialsMatched: "TorCircuitCredentialsMatched", +}); + +/** + * Wrapper error class for errors raised during TorProvider.init. + */ +export class TorProviderInitError extends Error { + /** + * Create a new instance. + * + * @param {any} error - The raised error that we want to wrap. + */ + constructor(error) { + super(error?.message, { cause: error }); + this.name = "TorProviderInitError"; + } +} + +/** + * Bootstrap errors raised by the TorProvider. + */ +export class TorBootstrapError extends Error { + /** + * Create a new instance. + * + * @param {object} details - Details about the error. + * @param {string} details.summary - A summary of the error. + * @param {string} details.phase - The bootstrap phase when the error occured. + * @param {string} details.reason - The reason for the bootsrap failure. + */ + constructor(details) { + super(details.summary); + this.name = "TorBootstrapError"; + this.phase = details.phase; + this.reason = details.reason; + } +} + +export const TorProviders = Object.freeze({ + none: 0, + tor: 1, +}); + +/** + * @typedef {object} LogEntry An object with a log message + * @property {string} timestamp The local date-time stamp at which we received the message + * @property {string} type The message level + * @property {string} msg The message + */ + +/** + * The factory to get a Tor provider. + * Currently we support only TorProvider, i.e., the one that interacts with + * C-tor through the control port protocol. + */ +export class TorProviderBuilder { + /** + * A promise with the instance of the provider that we are using. + * + * @type {Promise<TorProvider>?} + */ + static #provider = null; + + /** + * A record of the log messages from all TorProvider instances. + * + * @type {LogEntry[]} + */ + static #log = []; + + /** + * Get a record of historic log entries. + * + * @returns {LogEntry[]} - The record of entries. + */ + static getLog() { + return structuredClone(this.#log); + } + + /** + * The limit on the number of log entries we should store. + * + * @type {integer} + */ + static #logLimit; + + /** + * The observer that checks for new TorLog messages. + * + * @type {Function} + */ + static #logObserver; + + /** + * Add a new log message. + * + * @param {LogEntry} logEntry - The log entry to add. + */ + static #addLogEntry(logEntry) { + if (this.#logLimit > 0 && this.#log.length >= this.#logLimit) { + this.#log.splice(0, 1); + } + this.#log.push(logEntry); + } + + /** + * The observer that checks when the tor process exits, and reinitializes the + * provider. + * + * @type {Function} + */ + static #exitObserver = null; + + /** + * Tell whether the browser UI is ready. + * We ignore any errors until it is because we cannot show them. + * + * @type {boolean} + */ + static #uiReady = false; + + /** + * Initialize the provider of choice. + */ + static init() { + this.#logLimit = Services.prefs.getIntPref( + "extensions.torlauncher.max_tor_log_entries", + 1000 + ); + this.#logObserver = subject => { + this.#addLogEntry(subject.wrappedJSObject); + }; + Services.obs.addObserver(this.#logObserver, TorProviderTopics.TorLog); + + switch (this.providerType) { + case TorProviders.tor: + // Even though initialization of the initial TorProvider is + // asynchronous, we do not expect the caller to await it. The reason is + // that any call to build() will wait the initialization anyway (and + // re-throw any initialization error). + this.#initTorProvider(); + break; + case TorProviders.none: + lazy.TorLauncherUtil.setProxyConfiguration( + lazy.TorLauncherUtil.getPreferredSocksConfiguration() + ); + break; + default: + console.error(`Unknown tor provider ${this.providerType}.`); + break; + } + } + + /** + * Replace #provider with a new instance. + * + * @returns {Promise<TorProvider>} The new instance. + */ + static #initTorProvider() { + if (!this.#exitObserver) { + this.#exitObserver = this.#torExited.bind(this); + Services.obs.addObserver( + this.#exitObserver, + TorProviderTopics.ProcessExited + ); + } + + // NOTE: We need to ensure that the #provider is set as soon + // TorProviderBuilder.init is called. + // I.e. it should be safe to call + // TorProviderBuilder.init(); + // TorProviderBuilder.build(); + // without any await. + // + // Therefore, we await the oldProvider within the Promise rather than make + // #initTorProvider async. + // + // In particular, this is needed by TorConnect when the user has selected + // quickstart, in which case `TorConnect.init` will immediately request the + // provider. See tor-browser#41921. + this.#provider = this.#replaceTorProvider(this.#provider); + return this.#provider; + } + + /** + * Replace a TorProvider instance. Resolves once the TorProvider is + * initialised. + * + * @param {Promise<TorProvider>?} oldProvider - The previous's provider's + * promise, if any. + * @returns {TorProvider} The new TorProvider instance. + */ + static async #replaceTorProvider(oldProvider) { + try { + // Uninitialise the old TorProvider, if there is any. + (await oldProvider)?.uninit(); + } catch {} + const provider = new lazy.TorProvider(); + try { + await provider.init(); + } catch (error) { + // Wrap in an error type for callers to know whether the error comes from + // initialisation or something else. + throw new TorProviderInitError(error); + } + return provider; + } + + static uninit() { + this.#provider?.then(provider => { + provider.uninit(); + this.#provider = null; + }); + if (this.#exitObserver) { + Services.obs.removeObserver( + this.#exitObserver, + TorProviderTopics.ProcessExited + ); + this.#exitObserver = null; + } + Services.obs.removeObserver(this.#logObserver, TorProviderTopics.TorLog); + } + + /** + * Build a provider. + * This method will wait for the system to be initialized, and allows you to + * catch also any initialization errors. + * + * @returns {TorProvider} A TorProvider instance + */ + static async build() { + if (!this.#provider && this.providerType === TorProviders.none) { + throw new Error( + "Tor Browser has been configured to use only the proxy functionalities." + ); + } else if (!this.#provider) { + throw new Error( + "The provider has not been initialized or already uninitialized." + ); + } + return this.#provider; + } + + /** + * Check if the provider has been succesfully initialized when the first + * browser window is shown. + * This is a workaround we need because ideally we would like the tor process + * to start as soon as possible, to avoid delays in the about:torconnect page, + * but we should modify TorConnect and about:torconnect to handle this case + * there with a better UX. + */ + static async firstWindowLoaded() { + // FIXME: Just integrate this with the about:torconnect or about:tor UI. + if ( + !lazy.TorLauncherUtil.shouldStartAndOwnTor || + this.providerType !== TorProviders.tor + ) { + // If we are not managing the Tor daemon we cannot restart it, so just + // early return. + return; + } + let running = false; + try { + const provider = await this.#provider; + // The initialization might have succeeded, but so far we have ignored any + // error notification. So, check that the process has not exited after the + // provider has been initialized successfully, but the UI was not ready + // yet. + running = provider.isRunning; + } catch { + // Not even initialized, running is already false. + } + while (!running && lazy.TorLauncherUtil.showRestartPrompt(true)) { + try { + await this.#initTorProvider(); + running = true; + } catch {} + } + // The user might have canceled the restart, but at this point the UI is + // ready in any case. + this.#uiReady = true; + } + + static async #torExited() { + if (!this.#uiReady) { + console.warn( + `Seen ${TorProviderTopics.ProcessExited}, but not doing anything because the UI is not ready yet.` + ); + return; + } + while (lazy.TorLauncherUtil.showRestartPrompt(false)) { + try { + await this.#initTorProvider(); + break; + } catch {} + } + } + + /** + * Return the provider chosen by the user. + * This function checks the TOR_PROVIDER environment variable and if it is a + * known provider, it returns its associated value. + * Otherwise, if it is not valid, the C tor implementation is chosen as the + * default one. + * + * @returns {number} An entry from TorProviders + */ + static get providerType() { + // TODO: Add a preference to permanently save this without and avoid always + // using an environment variable. + let provider = TorProviders.tor; + const kEnvName = "TOR_PROVIDER"; + if ( + Services.env.exists(kEnvName) && + Services.env.get(kEnvName) in TorProviders + ) { + provider = TorProviders[Services.env.get(kEnvName)]; + } + return provider; + } +} diff --git a/toolkit/components/tor-launcher/TorStartupService.sys.mjs b/toolkit/components/tor-launcher/TorStartupService.sys.mjs @@ -0,0 +1,50 @@ +const lazy = {}; + +// We will use the modules only when the profile is loaded, so prefer lazy +// loading +ChromeUtils.defineESModuleGetters(lazy, { + TorLauncherUtil: "resource://gre/modules/TorLauncherUtil.sys.mjs", + TorProviderBuilder: "resource://gre/modules/TorProviderBuilder.sys.mjs", +}); + +/* Browser observer topis */ +const BrowserTopics = Object.freeze({ + ProfileAfterChange: "profile-after-change", + QuitApplicationGranted: "quit-application-granted", +}); + +let gInited = false; + +/** + * This class is registered as an observer, and will be instanced automatically + * by Firefox. + * When it observes profile-after-change, it initializes whatever is needed to + * launch Tor. + */ +export class TorStartupService { + observe(aSubject, aTopic) { + if (aTopic === BrowserTopics.ProfileAfterChange && !gInited) { + this.#init(); + } else if (aTopic === BrowserTopics.QuitApplicationGranted) { + this.#uninit(); + } + } + + #init() { + Services.obs.addObserver(this, BrowserTopics.QuitApplicationGranted); + + // Theoretically, build() is expected to await the initialization of the + // provider, and anything needing the Tor Provider should be able to just + // await on TorProviderBuilder.build(). + lazy.TorProviderBuilder.init(); + + gInited = true; + } + + #uninit() { + Services.obs.removeObserver(this, BrowserTopics.QuitApplicationGranted); + + lazy.TorProviderBuilder.uninit(); + lazy.TorLauncherUtil.cleanupTempDirectories(); + } +} diff --git a/toolkit/components/tor-launcher/components.conf b/toolkit/components/tor-launcher/components.conf @@ -0,0 +1,10 @@ +Classes = [ + { + "cid": "{df46c65d-be2b-4d16-b280-69733329eecf}", + "contract_ids": [ + "@torproject.org/tor-startup-service;1" + ], + "esModule": "resource://gre/modules/TorStartupService.sys.mjs", + "constructor": "TorStartupService", + }, +] diff --git a/toolkit/components/tor-launcher/moz.build b/toolkit/components/tor-launcher/moz.build @@ -0,0 +1,19 @@ +EXTRA_JS_MODULES += [ + "TorBootstrapRequest.sys.mjs", + "TorControlPort.sys.mjs", + "TorLauncherUtil.sys.mjs", + "TorParsers.sys.mjs", + "TorProcess.sys.mjs", + "TorProcessAndroid.sys.mjs", + "TorProvider.sys.mjs", + "TorProviderBuilder.sys.mjs", + "TorStartupService.sys.mjs", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +EXTRA_COMPONENTS += [ + "tor-launcher.manifest", +] diff --git a/toolkit/components/tor-launcher/tor-launcher.manifest b/toolkit/components/tor-launcher/tor-launcher.manifest @@ -0,0 +1,2 @@ +category profile-after-change TorStartupService @torproject.org/tor-startup-service;1 +category browser-first-window-ready resource://gre/modules/TorProviderBuilder.sys.mjs TorProviderBuilder.firstWindowLoaded