This is an automated email from the git hooks/post-receive script.
richard pushed a commit to branch tor-browser-102.3.0esr-12.0-2 in repository tor-browser.
commit 2810ae48b167235b668e730c00650299a57b6818 Author: Richard Pospesel richard@torproject.org AuthorDate: Fri Aug 6 16:39:03 2021 +0200
Bug 40597: Implement TorSettings module
- migrated in-page settings read/write implementation from about:preferences#tor to the TorSettings module - TorSettings initially loads settings from the tor daemon, and saves them to firefox prefs - TorSettings notifies observers when a setting has changed; currently only QuickStart notification is implemented for parity with previous preference notify logic in about:torconnect and about:preferences#tor - about:preferences#tor, and about:torconnect now read and write settings thorugh the TorSettings module - all tor settings live in the torbrowser.settings.* preference branch - removed unused pref modify permission for about:torconnect content page from AsyncPrefs.jsm
Bug 40645: Migrate Moat APIs to Moat.jsm module --- browser/components/sessionstore/SessionStore.jsm | 5 + browser/modules/BridgeDB.jsm | 61 ++ browser/modules/Moat.jsm | 814 +++++++++++++++ browser/modules/TorConnect.jsm | 1081 ++++++++++++++++++++ browser/modules/TorProtocolService.jsm | 510 +++++++++ browser/modules/TorSettings.jsm | 788 ++++++++++++++ browser/modules/moz.build | 4 + .../processsingleton/MainProcessSingleton.jsm | 3 + 8 files changed, 3266 insertions(+)
diff --git a/browser/components/sessionstore/SessionStore.jsm b/browser/components/sessionstore/SessionStore.jsm index 4611737ca918..33724bdc1ccb 100644 --- a/browser/components/sessionstore/SessionStore.jsm +++ b/browser/components/sessionstore/SessionStore.jsm @@ -234,6 +234,11 @@ ChromeUtils.defineModuleGetter( "resource://gre/modules/sessionstore/SessionHistory.jsm" );
+// FIXME: Is this really necessary? +const { TorProtocolService } = ChromeUtils.import( + "resource:///modules/TorProtocolService.jsm" +); + XPCOMUtils.defineLazyServiceGetters(this, { gScreenManager: ["@mozilla.org/gfx/screenmanager;1", "nsIScreenManager"], }); diff --git a/browser/modules/BridgeDB.jsm b/browser/modules/BridgeDB.jsm new file mode 100644 index 000000000000..50665710ebf4 --- /dev/null +++ b/browser/modules/BridgeDB.jsm @@ -0,0 +1,61 @@ +"use strict"; + +var EXPORTED_SYMBOLS = ["BridgeDB"]; + +const { MoatRPC } = ChromeUtils.import("resource:///modules/Moat.jsm"); + +var BridgeDB = { + _moatRPC: null, + _challenge: null, + _image: null, + _bridges: null, + + get currentCaptchaImage() { + return this._image; + }, + + get currentBridges() { + return this._bridges; + }, + + async submitCaptchaGuess(solution) { + if (!this._moatRPC) { + this._moatRPC = new MoatRPC(); + await this._moatRPC.init(); + } + + const response = await this._moatRPC.check( + "obfs4", + this._challenge, + solution, + false + ); + this._bridges = response?.bridges; + return this._bridges; + }, + + async requestNewCaptchaImage() { + try { + if (!this._moatRPC) { + this._moatRPC = new MoatRPC(); + await this._moatRPC.init(); + } + + const response = await this._moatRPC.fetch(["obfs4"]); + this._challenge = response.challenge; + this._image = + "data:image/jpeg;base64," + encodeURIComponent(response.image); + } catch (err) { + console.log(`error : ${err}`); + } + return this._image; + }, + + close() { + this._moatRPC?.uninit(); + this._moatRPC = null; + this._challenge = null; + this._image = null; + this._bridges = null; + }, +}; diff --git a/browser/modules/Moat.jsm b/browser/modules/Moat.jsm new file mode 100644 index 000000000000..90a6ae4e521c --- /dev/null +++ b/browser/modules/Moat.jsm @@ -0,0 +1,814 @@ +"use strict"; + +var EXPORTED_SYMBOLS = ["MoatRPC"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const { Subprocess } = ChromeUtils.import( + "resource://gre/modules/Subprocess.jsm" +); + +const { TorLauncherUtil } = ChromeUtils.import( + "resource://torlauncher/modules/tl-util.jsm" +); + +const { TorProtocolService } = ChromeUtils.import( + "resource:///modules/TorProtocolService.jsm" +); + +const { TorSettings, TorBridgeSource } = ChromeUtils.import( + "resource:///modules/TorSettings.jsm" +); + +const TorLauncherPrefs = Object.freeze({ + bridgedb_front: "extensions.torlauncher.bridgedb_front", + bridgedb_reflector: "extensions.torlauncher.bridgedb_reflector", + moat_service: "extensions.torlauncher.moat_service", +}); + +// Config keys used to query tor daemon properties +const TorConfigKeys = Object.freeze({ + clientTransportPlugin: "ClientTransportPlugin", +}); + +// +// Launches and controls the PT process lifetime +// +class MeekTransport { + constructor() { + this._inited = false; + this._meekClientProcess = null; + this._meekProxyType = null; + this._meekProxyAddress = null; + this._meekProxyPort = 0; + this._meekProxyUsername = null; + this._meekProxyPassword = null; + } + + // launches the meekprocess + async init() { + // ensure we haven't already init'd + if (this._inited) { + throw new Error("MeekTransport: Already initialized"); + } + + // cleanup function for killing orphaned pt process + let onException = () => {}; + try { + // figure out which pluggable transport to use + const supportedTransports = ["meek", "meek_lite"]; + let transportPlugins = await TorProtocolService.readStringArraySetting( + TorConfigKeys.clientTransportPlugin + ); + + let { meekTransport, meekClientPath, meekClientArgs } = (() => { + for (const line of transportPlugins) { + let tokens = line.split(" "); + if (tokens.length > 2 && tokens[1] == "exec") { + let transportArray = tokens[0].split(",").map(aStr => aStr.trim()); + let transport = transportArray.find(aTransport => + supportedTransports.includes(aTransport) + ); + + if (transport != undefined) { + return { + meekTransport: transport, + meekClientPath: tokens[2], + meekClientArgs: tokens.slice(3), + }; + } + } + } + + return { + meekTransport: null, + meekClientPath: null, + meekClientArgs: null, + }; + })(); + + // Convert meek client path to absolute path if necessary + let meekWorkDir = await TorLauncherUtil.getTorFile( + "pt-startup-dir", + false + ); + let re = TorLauncherUtil.isWindows ? /^[A-Za-z]:\/ : /^//; + if (!re.test(meekClientPath)) { + let meekPath = meekWorkDir.clone(); + meekPath.appendRelativePath(meekClientPath); + meekClientPath = meekPath.path; + } + + // Construct the per-connection arguments. + let meekClientEscapedArgs = ""; + const meekReflector = Services.prefs.getStringPref( + TorLauncherPrefs.bridgedb_reflector + ); + + // Escape aValue per section 3.5 of the PT specification: + // First the "<Key>=<Value>" formatted arguments MUST be escaped, + // such that all backslash, equal sign, and semicolon characters + // are escaped with a backslash. + let escapeArgValue = aValue => { + if (!aValue) { + return ""; + } + + let rv = aValue.replace(/\/g, "\\"); + rv = rv.replace(/=/g, "\="); + rv = rv.replace(/;/g, "\;"); + return rv; + }; + + if (meekReflector) { + meekClientEscapedArgs += "url="; + meekClientEscapedArgs += escapeArgValue(meekReflector); + } + const meekFront = Services.prefs.getStringPref( + TorLauncherPrefs.bridgedb_front + ); + if (meekFront) { + if (meekClientEscapedArgs.length) { + meekClientEscapedArgs += ";"; + } + meekClientEscapedArgs += "front="; + meekClientEscapedArgs += escapeArgValue(meekFront); + } + + // Setup env and start meek process + let ptStateDir = TorLauncherUtil.getTorFile("tordatadir", false); + let meekHelperProfileDir = TorLauncherUtil.getTorFile( + "pt-profiles-dir", + true + ); + ptStateDir.append("pt_state"); // Match what tor uses. + meekHelperProfileDir.appendRelativePath("profile.moat-http-helper"); + + let envAdditions = { + TOR_PT_MANAGED_TRANSPORT_VER: "1", + TOR_PT_STATE_LOCATION: ptStateDir.path, + TOR_PT_EXIT_ON_STDIN_CLOSE: "1", + TOR_PT_CLIENT_TRANSPORTS: meekTransport, + TOR_BROWSER_MEEK_PROFILE: meekHelperProfileDir.path, + }; + if (TorSettings.proxy.enabled) { + envAdditions.TOR_PT_PROXY = TorSettings.proxy.uri; + } + + let opts = { + command: meekClientPath, + arguments: meekClientArgs, + workdir: meekWorkDir.path, + environmentAppend: true, + environment: envAdditions, + stderr: "pipe", + }; + + // Launch meek client + let meekClientProcess = await Subprocess.call(opts); + // kill our process if exception is thrown + onException = () => { + meekClientProcess.kill(); + }; + + // Callback chain for reading stderr + let stderrLogger = async () => { + if (this._meekClientProcess) { + let errString = await this._meekClientProcess.stderr.readString(); + console.log(`MeekTransport: stderr => ${errString}`); + await stderrLogger(); + } + }; + stderrLogger(); + + // Read pt's stdout until terminal (CMETHODS DONE) is reached + // returns array of lines for parsing + let getInitLines = async (stdout = "") => { + let string = await meekClientProcess.stdout.readString(); + stdout += string; + + // look for the final message + const CMETHODS_DONE = "CMETHODS DONE"; + let endIndex = stdout.lastIndexOf(CMETHODS_DONE); + if (endIndex != -1) { + endIndex += CMETHODS_DONE.length; + return stdout.substr(0, endIndex).split("\n"); + } + return getInitLines(stdout); + }; + + // read our lines from pt's stdout + let meekInitLines = await getInitLines(); + // tokenize our pt lines + let meekInitTokens = meekInitLines.map(line => { + let tokens = line.split(" "); + return { + keyword: tokens[0], + args: tokens.slice(1), + }; + }); + + let meekProxyType = null; + let meekProxyAddr = null; + let meekProxyPort = 0; + + // parse our pt tokens + for (const { keyword, args } of meekInitTokens) { + const argsJoined = args.join(" "); + let keywordError = false; + switch (keyword) { + case "VERSION": { + if (args.length != 1 || args[0] !== "1") { + keywordError = true; + } + break; + } + case "PROXY": { + if (args.length != 1 || args[0] !== "DONE") { + keywordError = true; + } + break; + } + case "CMETHOD": { + if (args.length != 3) { + keywordError = true; + break; + } + const transport = args[0]; + const proxyType = args[1]; + const addrPortString = args[2]; + const addrPort = addrPortString.split(":"); + + if (transport !== meekTransport) { + throw new Error( + `MeekTransport: Expected ${meekTransport} but found ${transport}` + ); + } + if (!["socks4", "socks4a", "socks5"].includes(proxyType)) { + throw new Error( + `MeekTransport: Invalid proxy type => ${proxyType}` + ); + } + if (addrPort.length != 2) { + throw new Error( + `MeekTransport: Invalid proxy address => ${addrPortString}` + ); + } + const addr = addrPort[0]; + const port = parseInt(addrPort[1]); + if (port < 1 || port > 65535) { + throw new Error(`MeekTransport: Invalid proxy port => ${port}`); + } + + // convert proxy type to strings used by protocol-proxy-servce + meekProxyType = proxyType === "socks5" ? "socks" : "socks4"; + meekProxyAddr = addr; + meekProxyPort = port; + + break; + } + // terminal + case "CMETHODS": { + if (args.length != 1 || args[0] !== "DONE") { + keywordError = true; + } + break; + } + // errors (all fall through): + case "VERSION-ERROR": + case "ENV-ERROR": + case "PROXY-ERROR": + case "CMETHOD-ERROR": + throw new Error(`MeekTransport: ${keyword} => '${argsJoined}'`); + } + if (keywordError) { + throw new Error( + `MeekTransport: Invalid ${keyword} keyword args => '${argsJoined}'` + ); + } + } + + this._meekClientProcess = meekClientProcess; + // register callback to cleanup on process exit + this._meekClientProcess.wait().then(exitObj => { + this._meekClientProcess = null; + this.uninit(); + }); + + this._meekProxyType = meekProxyType; + this._meekProxyAddress = meekProxyAddr; + this._meekProxyPort = meekProxyPort; + + // socks5 + if (meekProxyType === "socks") { + if (meekClientEscapedArgs.length <= 255) { + this._meekProxyUsername = meekClientEscapedArgs; + this._meekProxyPassword = "\x00"; + } else { + this._meekProxyUsername = meekClientEscapedArgs.substring(0, 255); + this._meekProxyPassword = meekClientEscapedArgs.substring(255); + } + // socks4 + } else { + this._meekProxyUsername = meekClientEscapedArgs; + this._meekProxyPassword = undefined; + } + + this._inited = true; + } catch (ex) { + onException(); + throw ex; + } + } + + async uninit() { + this._inited = false; + + await this._meekClientProcess?.kill(); + this._meekClientProcess = null; + this._meekProxyType = null; + this._meekProxyAddress = null; + this._meekProxyPort = 0; + this._meekProxyUsername = null; + this._meekProxyPassword = null; + } +} + +// +// Callback object with a cached promise for the returned Moat data +// +class MoatResponseListener { + constructor() { + this._response = ""; + // we need this promise here because await nsIHttpChannel::asyncOpen does + // not return only once the request is complete, it seems to return + // after it begins, so we have to get the result from this listener object. + // This promise is only resolved once onStopRequest is called + this._responsePromise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + } + + // callers wait on this for final response + response() { + return this._responsePromise; + } + + // noop + onStartRequest(request) {} + + // resolve or reject our Promise + onStopRequest(request, status) { + try { + if (!Components.isSuccessCode(status)) { + const errorMessage = TorLauncherUtil.getLocalizedStringForError(status); + this._reject(new Error(errorMessage)); + } + if (request.responseStatus != 200) { + this._reject(new Error(request.responseStatusText)); + } + } catch (err) { + this._reject(err); + } + this._resolve(this._response); + } + + // read response data + onDataAvailable(request, stream, offset, length) { + const scriptableStream = Cc[ + "@mozilla.org/scriptableinputstream;1" + ].createInstance(Ci.nsIScriptableInputStream); + scriptableStream.init(stream); + this._response += scriptableStream.read(length); + } +} + +class InternetTestResponseListener { + constructor() { + this._promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + } + + // callers wait on this for final response + get status() { + return this._promise; + } + + onStartRequest(request) {} + + // resolve or reject our Promise + onStopRequest(request, status) { + let statuses = {}; + try { + statuses = { + components: status, + successful: Components.isSuccessCode(status), + }; + try { + if (statuses.successful) { + statuses.http = request.responseStatus; + statuses.date = request.getResponseHeader("Date"); + } + } catch (err) { + console.warn( + "Successful request, but could not get the HTTP status or date", + err + ); + } + } catch (err) { + this._reject(err); + } + this._resolve(statuses); + } + + onDataAvailable(request, stream, offset, length) { + // We do not care of the actual data, as long as we have a successful + // connection + } +} + +// constructs the json objects and sends the request over moat +class MoatRPC { + constructor() { + this._meekTransport = null; + this._inited = false; + } + + get inited() { + return this._inited; + } + + async init() { + if (this._inited) { + throw new Error("MoatRPC: Already initialized"); + } + + let meekTransport = new MeekTransport(); + await meekTransport.init(); + this._meekTransport = meekTransport; + this._inited = true; + } + + async uninit() { + await this._meekTransport?.uninit(); + this._meekTransport = null; + this._inited = false; + } + + _makeHttpHandler(uriString) { + if (!this._inited) { + throw new Error("MoatRPC: Not initialized"); + } + + const proxyType = this._meekTransport._meekProxyType; + const proxyAddress = this._meekTransport._meekProxyAddress; + const proxyPort = this._meekTransport._meekProxyPort; + const proxyUsername = this._meekTransport._meekProxyUsername; + const proxyPassword = this._meekTransport._meekProxyPassword; + + const proxyPS = Cc[ + "@mozilla.org/network/protocol-proxy-service;1" + ].getService(Ci.nsIProtocolProxyService); + const flags = Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST; + const noTimeout = 0xffffffff; // UINT32_MAX + const proxyInfo = proxyPS.newProxyInfoWithAuth( + proxyType, + proxyAddress, + proxyPort, + proxyUsername, + proxyPassword, + undefined, + undefined, + flags, + noTimeout, + undefined + ); + + const uri = Services.io.newURI(uriString); + // There does not seem to be a way to directly create an nsILoadInfo from + // JavaScript, so we create a throw away non-proxied channel to get one. + const secFlags = Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL; + const loadInfo = Services.io.newChannelFromURI( + uri, + undefined, + Services.scriptSecurityManager.getSystemPrincipal(), + undefined, + secFlags, + Ci.nsIContentPolicy.TYPE_OTHER + ).loadInfo; + + const httpHandler = Services.io + .getProtocolHandler("http") + .QueryInterface(Ci.nsIHttpProtocolHandler); + const ch = httpHandler + .newProxiedChannel(uri, proxyInfo, 0, undefined, loadInfo) + .QueryInterface(Ci.nsIHttpChannel); + + // remove all headers except for 'Host" + const headers = []; + ch.visitRequestHeaders({ + visitHeader: (key, val) => { + if (key !== "Host") { + headers.push(key); + } + }, + }); + headers.forEach(key => ch.setRequestHeader(key, "", false)); + + return ch; + } + + async _makeRequest(procedure, args) { + const procedureURIString = `${Services.prefs.getStringPref( + TorLauncherPrefs.moat_service + )}/${procedure}`; + const ch = this._makeHttpHandler(procedureURIString); + + // Arrange for the POST data to be sent. + const argsJson = JSON.stringify(args); + + const inStream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( + Ci.nsIStringInputStream + ); + inStream.setData(argsJson, argsJson.length); + const upChannel = ch.QueryInterface(Ci.nsIUploadChannel); + const contentType = "application/vnd.api+json"; + upChannel.setUploadStream(inStream, contentType, argsJson.length); + ch.requestMethod = "POST"; + + // Make request + const listener = new MoatResponseListener(); + await ch.asyncOpen(listener, ch); + + // wait for response + const responseJSON = await listener.response(); + + // parse that JSON + return JSON.parse(responseJSON); + } + + async testInternetConnection() { + const uri = `${Services.prefs.getStringPref( + TorLauncherPrefs.moat_service + )}/circumvention/countries`; + const ch = this._makeHttpHandler(uri); + ch.requestMethod = "HEAD"; + + const listener = new InternetTestResponseListener(); + await ch.asyncOpen(listener, ch); + return listener.status; + } + + // + // Moat APIs + // + + // Receive a CAPTCHA challenge, takes the following parameters: + // - transports: array of transport strings available to us eg: ["obfs4", "meek"] + // + // returns an object with the following fields: + // - transport: a transport string the moat server decides it will send you selected + // from the list of provided transports + // - image: a base64 encoded jpeg with the captcha to complete + // - challenge: a nonce/cookie string associated with this request + async fetch(transports) { + if ( + // ensure this is an array + Array.isArray(transports) && + // ensure array has values + !!transports.length && + // ensure each value in the array is a string + transports.reduce((acc, cur) => acc && typeof cur === "string", true) + ) { + const args = { + data: [ + { + version: "0.1.0", + type: "client-transports", + supported: transports, + }, + ], + }; + const response = await this._makeRequest("fetch", args); + if ("errors" in response) { + const code = response.errors[0].code; + const detail = response.errors[0].detail; + throw new Error(`MoatRPC: ${detail} (${code})`); + } + + const transport = response.data[0].transport; + const image = response.data[0].image; + const challenge = response.data[0].challenge; + + return { transport, image, challenge }; + } + throw new Error("MoatRPC: fetch() expects a non-empty array of strings"); + } + + // Submit an answer for a CAPTCHA challenge and get back bridges, takes the following + // parameters: + // - transport: the transport string associated with a previous fetch request + // - challenge: the nonce string associated with the fetch request + // - solution: solution to the CAPTCHA associated with the fetch request + // - qrcode: true|false whether we want to get back a qrcode containing the bridge strings + // + // returns an object with the following fields: + // - bridges: an array of bridge line strings + // - qrcode: base64 encoded jpeg of bridges if requested, otherwise null + // if the provided solution is incorrect, returns an empty object + async check(transport, challenge, solution, qrcode) { + const args = { + data: [ + { + id: "2", + version: "0.1.0", + type: "moat-solution", + transport, + challenge, + solution, + qrcode: qrcode ? "true" : "false", + }, + ], + }; + const response = await this._makeRequest("check", args); + if ("errors" in response) { + const code = response.errors[0].code; + const detail = response.errors[0].detail; + if (code == 419 && detail === "The CAPTCHA solution was incorrect.") { + return {}; + } + + throw new Error(`MoatRPC: ${detail} (${code})`); + } + + const bridges = response.data[0].bridges; + const qrcodeImg = qrcode ? response.data[0].qrcode : null; + + return { bridges, qrcode: qrcodeImg }; + } + + // Convert received settings object to format used by TorSettings module + // In the event of error, just return null + _fixupSettings(settings) { + try { + let retval = TorSettings.defaultSettings(); + if ("bridges" in settings) { + retval.bridges.enabled = true; + switch (settings.bridges.source) { + case "builtin": + retval.bridges.source = TorBridgeSource.BuiltIn; + retval.bridges.builtin_type = settings.bridges.type; + // Tor Browser will periodically update the built-in bridge strings list using the + // circumvention_builtin() function, so we can ignore the bridge strings we have received here; + // BridgeDB only returns a subset of the available built-in bridges through the circumvention_settings() + // function which is fine for our 3rd parties, but we're better off ignoring them in Tor Browser, otherwise + // we get in a weird situation of needing to update our built-in bridges in a piece-meal fashion which + // seems over-complicated/error-prone + break; + case "bridgedb": + retval.bridges.source = TorBridgeSource.BridgeDB; + if (settings.bridges.bridge_strings) { + retval.bridges.bridge_strings = settings.bridges.bridge_strings; + retval.bridges.disabled_strings = []; + } else { + throw new Error( + "MoatRPC::_fixupSettings(): Received no bridge-strings for BridgeDB bridge source" + ); + } + break; + default: + throw new Error( + `MoatRPC::_fixupSettings(): Unexpected bridge source '${settings.bridges.source}'` + ); + } + } + if ("proxy" in settings) { + // TODO: populate proxy settings + } + if ("firewall" in settings) { + // TODO: populate firewall settings + } + return retval; + } catch (ex) { + console.log(ex.message); + return null; + } + } + + // Converts a list of settings objects received from BridgeDB to a list of settings objects + // understood by the TorSettings module + // In the event of error, returns and empty list + _fixupSettingsList(settingsList) { + try { + let retval = []; + for (let settings of settingsList) { + settings = this._fixupSettings(settings); + if (settings != null) { + retval.push(settings); + } + } + return retval; + } catch (ex) { + console.log(ex.message); + return []; + } + } + + // Request tor settings for the user optionally based on their location (derived + // from their IP), takes the following parameters: + // - transports: optional, an array of transports available to the client; if empty (or not + // given) returns settings using all working transports known to the server + // - country: optional, an ISO 3166-1 alpha-2 country code to request settings for; + // if not provided the country is determined by the user's IP address + // + // returns an array of settings objects in roughly the same format as the _settings + // object on the TorSettings module. + // - If the server cannot determine the user's country (and no country code is provided), + // then null is returned + // - If the country has no associated settings, an empty array is returned + async circumvention_settings(transports, country) { + const args = { + transports: transports ? transports : [], + country, + }; + const response = await this._makeRequest("circumvention/settings", args); + let settings = {}; + if ("errors" in response) { + const code = response.errors[0].code; + const detail = response.errors[0].detail; + if (code == 406) { + console.log( + "MoatRPC::circumvention_settings(): Cannot automatically determine user's country-code" + ); + // cannot determine user's country + return null; + } + + throw new Error(`MoatRPC: ${detail} (${code})`); + } else if ("settings" in response) { + settings.settings = this._fixupSettingsList(response.settings); + } + if ("country" in response) { + settings.country = response.country; + } + return settings; + } + + // Request a list of country codes with available censorship circumvention settings + // + // returns an array of ISO 3166-1 alpha-2 country codes which we can query settings + // for + async circumvention_countries() { + const args = {}; + return this._makeRequest("circumvention/countries", args); + } + + // Request a copy of the builtin bridges, takes the following parameters: + // - transports: optional, an array of transports we would like the latest bridge strings + // for; if empty (or not given) returns all of them + // + // returns a map whose keys are pluggable transport types and whose values are arrays of + // bridge strings for that type + async circumvention_builtin(transports) { + const args = { + transports: transports ? transports : [], + }; + const response = await this._makeRequest("circumvention/builtin", args); + if ("errors" in response) { + const code = response.errors[0].code; + const detail = response.errors[0].detail; + throw new Error(`MoatRPC: ${detail} (${code})`); + } + + let map = new Map(); + for (const [transport, bridge_strings] of Object.entries(response)) { + map.set(transport, bridge_strings); + } + + return map; + } + + // Request a copy of the defaul/fallback bridge settings, takes the following parameters: + // - transports: optional, an array of transports available to the client; if empty (or not + // given) returns settings using all working transports known to the server + // + // returns an array of settings objects in roughly the same format as the _settings + // object on the TorSettings module + async circumvention_defaults(transports) { + const args = { + transports: transports ? transports : [], + }; + const response = await this._makeRequest("circumvention/defaults", args); + if ("errors" in response) { + const code = response.errors[0].code; + const detail = response.errors[0].detail; + throw new Error(`MoatRPC: ${detail} (${code})`); + } else if ("settings" in response) { + return this._fixupSettingsList(response.settings); + } + return []; + } +} diff --git a/browser/modules/TorConnect.jsm b/browser/modules/TorConnect.jsm new file mode 100644 index 000000000000..cc0eeb2b1eba --- /dev/null +++ b/browser/modules/TorConnect.jsm @@ -0,0 +1,1081 @@ +"use strict"; + +var EXPORTED_SYMBOLS = [ + "InternetStatus", + "TorConnect", + "TorConnectTopics", + "TorConnectState", +]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const { setTimeout, clearTimeout } = ChromeUtils.import( + "resource://gre/modules/Timer.jsm" +); + +const { BrowserWindowTracker } = ChromeUtils.import( + "resource:///modules/BrowserWindowTracker.jsm" +); + +const { + TorProtocolService, + TorTopics, + TorBootstrapRequest, +} = ChromeUtils.import("resource:///modules/TorProtocolService.jsm"); + +const { TorLauncherUtil } = ChromeUtils.import( + "resource://torlauncher/modules/tl-util.jsm" +); + +const { + TorSettings, + TorSettingsTopics, + TorBuiltinBridgeTypes, +} = ChromeUtils.import("resource:///modules/TorSettings.jsm"); + +const { TorStrings } = ChromeUtils.import("resource:///modules/TorStrings.jsm"); + +const { MoatRPC } = ChromeUtils.import("resource:///modules/Moat.jsm"); + +/* Browser observer topis */ +const BrowserTopics = Object.freeze({ + ProfileAfterChange: "profile-after-change", +}); + +/* Relevant prefs used by tor-launcher */ +const TorLauncherPrefs = Object.freeze({ + prompt_at_startup: "extensions.torlauncher.prompt_at_startup", +}); + +const TorConnectPrefs = Object.freeze({ + censorship_level: "torbrowser.debug.censorship_level", +}); + +const TorConnectState = Object.freeze({ + /* Our initial state */ + Initial: "Initial", + /* In-between initial boot and bootstrapping, users can change tor network settings during this state */ + Configuring: "Configuring", + /* Tor is attempting to bootstrap with settings from censorship-circumvention db */ + AutoBootstrapping: "AutoBootstrapping", + /* Tor is bootstrapping */ + Bootstrapping: "Bootstrapping", + /* Passthrough state back to Configuring */ + Error: "Error", + /* Final state, after successful bootstrap */ + Bootstrapped: "Bootstrapped", + /* If we are using System tor or the legacy Tor-Launcher */ + Disabled: "Disabled", +}); + +/* + TorConnect State Transitions + + ┌─────────┐ ┌────────┐ + │ ▼ ▼ │ + │ ┌──────────────────────────────────────────────────────────┐ │ + ┌─┼────── │ Error │ ◀───┐ │ + │ │ └──────────────────────────────────────────────────────────┘ │ │ + │ │ ▲ │ │ + │ │ │ │ │ + │ │ │ │ │ + │ │ ┌───────────────────────┐ ┌──────────┐ │ │ + │ │ ┌──── │ Initial │ ────────────────────▶ │ Disabled │ │ │ + │ │ │ └───────────────────────┘ └──────────┘ │ │ + │ │ │ │ │ │ + │ │ │ │ beginBootstrap() │ │ + │ │ │ ▼ │ │ + │ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ + │ │ │ │ Bootstrapping │ ────┘ │ + │ │ │ └──────────────────────────────────────────────────────────┘ │ + │ │ │ │ ▲ │ │ + │ │ │ │ cancelBootstrap() │ beginBootstrap() └────┐ │ + │ │ │ ▼ │ │ │ + │ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ + │ │ └───▶ │ │ ─┼────┘ + │ │ │ │ │ + │ │ │ │ │ + │ │ │ Configuring │ │ + │ │ │ │ │ + │ │ │ │ │ + └─┼─────▶ │ │ │ + │ └──────────────────────────────────────────────────────────┘ │ + │ │ ▲ │ + │ │ beginAutoBootstrap() │ cancelAutoBootstrap() │ + │ ▼ │ │ + │ ┌───────────────────────┐ │ │ + └────── │ AutoBootstrapping │ ─┘ │ + └───────────────────────┘ │ + │ │ + │ │ + ▼ │ + ┌───────────────────────┐ │ + │ Bootstrapped │ ◀───────────────────────────────────┘ + └───────────────────────┘ +*/ + +/* Maps allowed state transitions + TorConnectStateTransitions[state] maps to an array of allowed states to transition to + This is just an encoding of the above transition diagram that we verify at runtime +*/ +const TorConnectStateTransitions = Object.freeze( + new Map([ + [ + TorConnectState.Initial, + [ + TorConnectState.Disabled, + TorConnectState.Bootstrapping, + TorConnectState.Configuring, + TorConnectState.Error, + ], + ], + [ + TorConnectState.Configuring, + [ + TorConnectState.AutoBootstrapping, + TorConnectState.Bootstrapping, + TorConnectState.Error, + ], + ], + [ + TorConnectState.AutoBootstrapping, + [ + TorConnectState.Configuring, + TorConnectState.Bootstrapped, + TorConnectState.Error, + ], + ], + [ + TorConnectState.Bootstrapping, + [ + TorConnectState.Configuring, + TorConnectState.Bootstrapped, + TorConnectState.Error, + ], + ], + [TorConnectState.Error, [TorConnectState.Configuring]], + // terminal states + [TorConnectState.Bootstrapped, []], + [TorConnectState.Disabled, []], + ]) +); + +/* Topics Notified by the TorConnect module */ +const TorConnectTopics = Object.freeze({ + StateChange: "torconnect:state-change", + BootstrapProgress: "torconnect:bootstrap-progress", + BootstrapComplete: "torconnect:bootstrap-complete", + BootstrapError: "torconnect:bootstrap-error", +}); + +// The StateCallback is a wrapper around an async function which executes during +// the lifetime of a TorConnect State. A system is also provided to allow this +// ongoing function to early-out via a per StateCallback on_transition callback +// which may be called externally when we need to early-out and move on to another +// state (for example, from Bootstrapping to Configuring in the event the user +// cancels a bootstrap attempt) +class StateCallback { + constructor(state, callback) { + this._state = state; + this._callback = callback; + this._init(); + } + + _init() { + // this context object is bound to the callback each time transition is + // attempted via begin() + this._context = { + // This callback may be overwritten in the _callback for each state + // States may have various pieces of work which need to occur + // before they can be exited (eg resource cleanup) + // See the _stateCallbacks map for examples + on_transition: nextState => {}, + + // flag used to determine if a StateCallback should early-out + // its work + _transitioning: false, + + // may be called within the StateCallback to determine if exit is possible + get transitioning() { + return this._transitioning; + }, + }; + } + + async begin(...args) { + console.log(`TorConnect: Entering ${this._state} state`); + this._init(); + try { + // this Promise will block until this StateCallback has completed its work + await Promise.resolve(this._callback.call(this._context, ...args)); + console.log(`TorConnect: Exited ${this._state} state`); + + // handled state transition + Services.obs.notifyObservers( + { state: this._nextState }, + TorConnectTopics.StateChange + ); + TorConnect._callback(this._nextState).begin(...this._nextStateArgs); + } catch (obj) { + TorConnect._changeState( + TorConnectState.Error, + obj?.message, + obj?.details + ); + } + } + + transition(nextState, ...args) { + this._nextState = nextState; + this._nextStateArgs = [...args]; + + // calls the on_transition callback to resolve any async work or do per-state cleanup + // this call to on_transition should resolve the async work currentlying going on in this.begin() + this._context.on_transition(nextState); + this._context._transitioning = true; + } +} + +// async method to sleep for a given amount of time +const debug_sleep = async ms => { + return new Promise((resolve, reject) => { + setTimeout(resolve, ms); + }); +}; + +const InternetStatus = Object.freeze({ + Unknown: -1, + Offline: 0, + Online: 1, +}); + +class InternetTest { + constructor() { + this._status = InternetStatus.Unknown; + this._error = null; + this._pending = false; + this._timeout = setTimeout(() => { + this._timeout = null; + this.test(); + }, this.timeoutRand()); + this.onResult = (online, date) => {}; + this.onError = err => {}; + } + + test() { + if (this._pending) { + return; + } + this.cancel(); + this._pending = true; + + this._testAsync() + .then(status => { + this._pending = false; + this._status = status.successful + ? InternetStatus.Online + : InternetStatus.Offline; + this.onResult(this.status, status.date); + }) + .catch(error => { + this._error = error; + this._pending = false; + this.onError(error); + }); + } + + cancel() { + if (this._timeout !== null) { + clearTimeout(this._timeout); + this._timeout = null; + } + } + + async _testAsync() { + // Callbacks for the Internet test are desirable, because we will be + // waiting both for the bootstrap, and for the Internet test. + // However, managing Moat with async/await is much easier as it avoids a + // callback hell, and it makes extra explicit that we are uniniting it. + const mrpc = new MoatRPC(); + let status = null; + let error = null; + try { + await mrpc.init(); + status = await mrpc.testInternetConnection(); + } catch (err) { + console.error("Error while checking the Internet connection", err); + error = err; + } finally { + mrpc.uninit(); + } + if (error !== null) { + throw error; + } + return status; + } + + get status() { + return this._status; + } + + get error() { + return this._error; + } + + // We randomize the Internet test timeout to make fingerprinting it harder, at least a little bit... + timeoutRand() { + const offset = 30000; + const randRange = 5000; + return offset + randRange * (Math.random() * 2 - 1); + } +} + +const TorConnect = (() => { + let retval = { + _state: TorConnectState.Initial, + _bootstrapProgress: 0, + _bootstrapStatus: null, + _internetStatus: InternetStatus.Unknown, + // list of country codes Moat has settings for + _countryCodes: [], + _countryNames: Object.freeze( + (() => { + const codes = Services.intl.getAvailableLocaleDisplayNames("region"); + const names = Services.intl.getRegionDisplayNames(undefined, codes); + let codesNames = {}; + for (let i = 0; i < codes.length; i++) { + codesNames[codes[i]] = names[i]; + } + return codesNames; + })() + ), + _detectedLocation: "", + _errorMessage: null, + _errorDetails: null, + _logHasWarningOrError: false, + _hasBootstrapEverFailed: false, + _transitionPromise: null, + + // This is used as a helper to make the state of about:torconnect persistent + // during a session, but TorConnect does not use this data at all. + _uiState: {}, + + /* These functions represent ongoing work associated with one of our states + Some of these functions are mostly empty, apart from defining an + on_transition function used to resolve their Promise */ + _stateCallbacks: Object.freeze( + new Map([ + /* Initial is never transitioned to */ + [ + TorConnectState.Initial, + new StateCallback(TorConnectState.Initial, async function() { + // The initial state doesn't actually do anything, so here is a skeleton for other + // states which do perform work + await new Promise(async (resolve, reject) => { + // This function is provided to signal to the callback that it is complete. + // It is called as a result of _changeState and at the very least must + // resolve the root Promise object within the StateCallback function + // The on_transition callback may also perform necessary cleanup work + this.on_transition = nextState => { + resolve(); + }; + + try { + // each state may have a sequence of async work to do + let asyncWork = async () => {}; + await asyncWork(); + + // after each block we may check for an opportunity to early-out + if (this.transitioning) { + return; + } + + // repeat the above pattern as necessary + } catch (err) { + // any thrown exceptions here will trigger a transition to the Error state + TorConnect._changeState( + TorConnectState.Error, + err?.message, + err?.details + ); + } + }); + }), + ], + /* Configuring */ + [ + TorConnectState.Configuring, + new StateCallback(TorConnectState.Configuring, async function() { + await new Promise(async (resolve, reject) => { + this.on_transition = nextState => { + resolve(); + }; + }); + }), + ], + /* Bootstrapping */ + [ + TorConnectState.Bootstrapping, + new StateCallback(TorConnectState.Bootstrapping, async function() { + // wait until bootstrap completes or we get an error + await new Promise(async (resolve, reject) => { + // debug hook to simulate censorship preventing bootstrapping + if ( + Services.prefs.getIntPref(TorConnectPrefs.censorship_level, 0) > + 0 + ) { + this.on_transition = nextState => { + resolve(); + }; + await debug_sleep(1500); + TorConnect._hasBootstrapEverFailed = true; + if ( + Services.prefs.getIntPref( + TorConnectPrefs.censorship_level, + 0 + ) === 2 + ) { + const codes = Object.keys(TorConnect._countryNames); + TorConnect._detectedLocation = + codes[Math.floor(Math.random() * codes.length)]; + } + TorConnect._changeState( + TorConnectState.Error, + "Bootstrap failed (for debugging purposes)", + "Error: Censorship simulation", + true + ); + TorProtocolService._torBootstrapDebugSetError(); + return; + } + + const tbr = new TorBootstrapRequest(); + const internetTest = new InternetTest(); + + let bootstrapError = ""; + let bootstrapErrorDetails = ""; + const maybeTransitionToError = () => { + if ( + internetTest.status === InternetStatus.Unknown && + internetTest.error === null + ) { + // We have been called by a failed bootstrap, but the internet test has not run yet - force + // it to run immediately! + internetTest.test(); + // Return from this call, because the Internet test's callback will call us again + return; + } + // Do not transition to the offline error until we are sure that also the bootstrap failed, in + // case Moat is down but the bootstrap can proceed anyway. + if (bootstrapError === "") { + return; + } + if (internetTest.status === InternetStatus.Offline) { + TorConnect._changeState( + TorConnectState.Error, + TorStrings.torConnect.offline, + "", + true + ); + } else { + // Give priority to the bootstrap error, in case the Internet test fails + TorConnect._hasBootstrapEverFailed = true; + TorConnect._changeState( + TorConnectState.Error, + bootstrapError, + bootstrapErrorDetails, + true + ); + } + }; + + this.on_transition = async nextState => { + if (nextState === TorConnectState.Configuring) { + // stop bootstrap process if user cancelled + internetTest.cancel(); + await tbr.cancel(); + } + resolve(); + }; + + tbr.onbootstrapstatus = (progress, status) => { + TorConnect._updateBootstrapStatus(progress, status); + }; + tbr.onbootstrapcomplete = () => { + internetTest.cancel(); + TorConnect._changeState(TorConnectState.Bootstrapped); + }; + tbr.onbootstraperror = (message, details) => { + // We have to wait for the Internet test to finish before sending the bootstrap error + bootstrapError = message; + bootstrapErrorDetails = details; + maybeTransitionToError(); + }; + + internetTest.onResult = (status, date) => { + // TODO: Use the date to save the clock skew? + TorConnect._internetStatus = status; + maybeTransitionToError(); + }; + internetTest.onError = () => { + maybeTransitionToError(); + }; + + tbr.bootstrap(); + }); + }), + ], + /* AutoBootstrapping */ + [ + TorConnectState.AutoBootstrapping, + new StateCallback(TorConnectState.AutoBootstrapping, async function( + countryCode + ) { + await new Promise(async (resolve, reject) => { + this.on_transition = nextState => { + resolve(); + }; + + // debug hook to simulate censorship preventing bootstrapping + { + const censorshipLevel = Services.prefs.getIntPref( + TorConnectPrefs.censorship_level, + 0 + ); + if (censorshipLevel > 1) { + this.on_transition = nextState => { + resolve(); + }; + // always fail even after manually selecting location specific settings + if (censorshipLevel == 3) { + await debug_sleep(2500); + TorConnect._changeState( + TorConnectState.Error, + "Error: censorship simulation", + "", + true + ); + return; + // only fail after auto selecting, manually selecting succeeds + } else if (censorshipLevel == 2 && !countryCode) { + await debug_sleep(2500); + TorConnect._changeState( + TorConnectState.Error, + "Error: Severe Censorship simulation", + "", + true + ); + return; + } + TorProtocolService._torBootstrapDebugSetError(); + } + } + + const throw_error = (message, details) => { + let err = new Error(message); + err.details = details; + throw err; + }; + + // lookup user's potential censorship circumvention settings from Moat service + try { + this.mrpc = new MoatRPC(); + await this.mrpc.init(); + + if (this.transitioning) { + return; + } + + const settings = await this.mrpc.circumvention_settings( + [...TorBuiltinBridgeTypes, "vanilla"], + countryCode + ); + + if (this.transitioning) { + return; + } + + if (settings?.country) { + TorConnect._detectedLocation = settings.country; + } + if (settings?.settings && settings.settings.length) { + this.settings = settings.settings; + } else { + try { + this.settings = await this.mrpc.circumvention_defaults([ + ...TorBuiltinBridgeTypes, + "vanilla", + ]); + } catch (err) { + console.error( + "We did not get localized settings, and default settings failed as well", + err + ); + } + } + if (this.settings === null || this.settings.length === 0) { + // The fallback has failed as well, so throw the original error + if (!TorConnect._detectedLocation) { + // unable to determine country + throw_error( + TorStrings.torConnect.autoBootstrappingFailed, + TorStrings.torConnect.cannotDetermineCountry + ); + } else { + // no settings available for country + throw_error( + TorStrings.torConnect.autoBootstrappingFailed, + TorStrings.torConnect.noSettingsForCountry + ); + } + } + + // apply each of our settings and try to bootstrap with each + try { + this.originalSettings = TorSettings.getSettings(); + + for (const [ + index, + currentSetting, + ] of this.settings.entries()) { + // we want to break here so we can fall through and restore original settings + if (this.transitioning) { + break; + } + + console.log( + `TorConnect: Attempting Bootstrap with configuration ${index + + 1}/${this.settings.length}` + ); + + TorSettings.setSettings(currentSetting); + await TorSettings.applySettings(); + + // build out our bootstrap request + const tbr = new TorBootstrapRequest(); + tbr.onbootstrapstatus = (progress, status) => { + TorConnect._updateBootstrapStatus(progress, status); + }; + tbr.onbootstraperror = (message, details) => { + console.log( + `TorConnect: Auto-Bootstrap error => ${message}; ${details}` + ); + }; + + // update transition callback for user cancel + this.on_transition = async nextState => { + if (nextState === TorConnectState.Configuring) { + await tbr.cancel(); + } + resolve(); + }; + + // begin bootstrap + if (await tbr.bootstrap()) { + // persist the current settings to preferences + TorSettings.saveToPrefs(); + TorConnect._changeState(TorConnectState.Bootstrapped); + return; + } + } + + // bootstrapped failed for all potential settings, so reset daemon to use original + TorSettings.setSettings(this.originalSettings); + await TorSettings.applySettings(); + TorSettings.saveToPrefs(); + + // only explicitly change state here if something else has not transitioned us + if (!this.transitioning) { + throw_error( + TorStrings.torConnect.autoBootstrappingFailed, + TorStrings.torConnect.autoBootstrappingAllFailed + ); + } + return; + } catch (err) { + // restore original settings in case of error + try { + TorSettings.setSettings(this.originalSettings); + await TorSettings.applySettings(); + } catch (errRestore) { + console.log( + `TorConnect: Failed to restore original settings => ${errRestore}` + ); + } + // throw to outer catch to transition us + throw err; + } + } catch (err) { + if (this.mrpc?.inited) { + // lookup countries which have settings available + TorConnect._countryCodes = await this.mrpc.circumvention_countries(); + } + TorConnect._changeState( + TorConnectState.Error, + err?.message, + err?.details, + true + ); + } finally { + // important to uninit MoatRPC object or else the pt process will live as long as tor-browser + this.mrpc?.uninit(); + } + }); + }), + ], + /* Bootstrapped */ + [ + TorConnectState.Bootstrapped, + new StateCallback(TorConnectState.Bootstrapped, async function() { + await new Promise((resolve, reject) => { + // on_transition not defined because no way to leave Bootstrapped state + // notify observers of bootstrap completion + Services.obs.notifyObservers( + null, + TorConnectTopics.BootstrapComplete + ); + }); + }), + ], + /* Error */ + [ + TorConnectState.Error, + new StateCallback(TorConnectState.Error, async function( + errorMessage, + errorDetails, + bootstrappingFailure + ) { + await new Promise((resolve, reject) => { + this.on_transition = async nextState => { + resolve(); + }; + + TorConnect._errorMessage = errorMessage; + TorConnect._errorDetails = errorDetails; + + Services.obs.notifyObservers( + { message: errorMessage, details: errorDetails }, + TorConnectTopics.BootstrapError + ); + + TorConnect._changeState(TorConnectState.Configuring); + }); + }), + ], + /* Disabled */ + [ + TorConnectState.Disabled, + new StateCallback(TorConnectState.Disabled, async function() { + await new Promise((resolve, reject) => { + // no-op, on_transition not defined because no way to leave Disabled state + }); + }), + ], + ]) + ), + + _callback(state) { + return this._stateCallbacks.get(state); + }, + + _changeState(newState, ...args) { + const prevState = this._state; + + // ensure this is a valid state transition + if (!TorConnectStateTransitions.get(prevState)?.includes(newState)) { + throw Error( + `TorConnect: Attempted invalid state transition from ${prevState} to ${newState}` + ); + } + + console.log( + `TorConnect: Try transitioning from ${prevState} to ${newState}` + ); + + // set our new state first so that state transitions can themselves trigger + // a state transition + this._state = newState; + + // call our state function and forward any args + this._callback(prevState).transition(newState, ...args); + }, + + _updateBootstrapStatus(progress, status) { + this._bootstrapProgress = progress; + this._bootstrapStatus = status; + + console.log( + `TorConnect: Bootstrapping ${this._bootstrapProgress}% complete (${this._bootstrapStatus})` + ); + Services.obs.notifyObservers( + { + progress: TorConnect._bootstrapProgress, + status: TorConnect._bootstrapStatus, + hasWarnings: TorConnect._logHasWarningOrError, + }, + TorConnectTopics.BootstrapProgress + ); + }, + + // init should be called on app-startup in MainProcessingSingleton.jsm + init() { + console.log("TorConnect: init()"); + + // delay remaining init until after profile-after-change + Services.obs.addObserver(this, BrowserTopics.ProfileAfterChange); + + this._callback(TorConnectState.Initial).begin(); + }, + + async observe(subject, topic, data) { + console.log(`TorConnect: Observed ${topic}`); + + switch (topic) { + /* Determine which state to move to from Initial */ + case BrowserTopics.ProfileAfterChange: { + if ( + TorLauncherUtil.useLegacyLauncher || + !TorProtocolService.ownsTorDaemon + ) { + // Disabled + this._changeState(TorConnectState.Disabled); + } else { + let observeTopic = addTopic => { + Services.obs.addObserver(this, addTopic); + console.log(`TorConnect: Observing topic '${addTopic}'`); + }; + + // register the Tor topics we always care about + observeTopic(TorTopics.ProcessExited); + observeTopic(TorTopics.LogHasWarnOrErr); + observeTopic(TorSettingsTopics.Ready); + } + Services.obs.removeObserver(this, topic); + break; + } + /* We need to wait until TorSettings have been loaded and applied before we can Quickstart */ + case TorSettingsTopics.Ready: { + if (this.shouldQuickStart) { + // Quickstart + this._changeState(TorConnectState.Bootstrapping); + } else { + // Configuring + this._changeState(TorConnectState.Configuring); + } + break; + } + case TorTopics.LogHasWarnOrErr: { + this._logHasWarningOrError = true; + break; + } + default: + // ignore + break; + } + }, + + /* + Various getters + */ + + get shouldShowTorConnect() { + // TorBrowser must control the daemon + return ( + TorProtocolService.ownsTorDaemon && + // and we're not using the legacy launcher + !TorLauncherUtil.useLegacyLauncher && + // if we have succesfully bootstraped, then no need to show TorConnect + this.state != TorConnectState.Bootstrapped + ); + }, + + get shouldQuickStart() { + // quickstart must be enabled + return ( + TorSettings.quickstart.enabled && + // and the previous bootstrap attempt must have succeeded + !Services.prefs.getBoolPref(TorLauncherPrefs.prompt_at_startup, true) + ); + }, + + get state() { + return this._state; + }, + + get bootstrapProgress() { + return this._bootstrapProgress; + }, + + get bootstrapStatus() { + return this._bootstrapStatus; + }, + + get internetStatus() { + return this._internetStatus; + }, + + get countryCodes() { + return this._countryCodes; + }, + + get countryNames() { + return this._countryNames; + }, + + get detectedLocation() { + return this._detectedLocation; + }, + + get errorMessage() { + return this._errorMessage; + }, + + get errorDetails() { + return this._errorDetails; + }, + + get logHasWarningOrError() { + return this._logHasWarningOrError; + }, + + get hasBootstrapEverFailed() { + return this._hasBootstrapEverFailed; + }, + + get uiState() { + return this._uiState; + }, + set uiState(newState) { + this._uiState = newState; + }, + + /* + These functions allow external consumers to tell TorConnect to transition states + */ + + beginBootstrap() { + console.log("TorConnect: beginBootstrap()"); + this._changeState(TorConnectState.Bootstrapping); + }, + + cancelBootstrap() { + console.log("TorConnect: cancelBootstrap()"); + this._changeState(TorConnectState.Configuring); + }, + + beginAutoBootstrap(countryCode) { + console.log("TorConnect: beginAutoBootstrap()"); + this._changeState(TorConnectState.AutoBootstrapping, countryCode); + }, + + cancelAutoBootstrap() { + console.log("TorConnect: cancelAutoBootstrap()"); + this._changeState(TorConnectState.Configuring); + }, + + /* + Further external commands and helper methods + */ + openTorPreferences() { + const win = BrowserWindowTracker.getTopWindow(); + win.switchToTabHavingURI("about:preferences#connection", true); + }, + + openTorConnect() { + const win = BrowserWindowTracker.getTopWindow(); + win.switchToTabHavingURI("about:torconnect", true, { + ignoreQueryString: true, + }); + }, + + viewTorLogs() { + const win = BrowserWindowTracker.getTopWindow(); + win.switchToTabHavingURI("about:preferences#connection-viewlogs", true); + }, + + async getCountryCodes() { + // Difference with the getter: this is to be called by TorConnectParent, and downloads + // the country codes if they are not already in cache. + if (this._countryCodes.length) { + return this._countryCodes; + } + const mrpc = new MoatRPC(); + try { + await mrpc.init(); + this._countryCodes = await mrpc.circumvention_countries(); + } catch (err) { + console.log("An error occurred while fetching country codes", err); + } finally { + mrpc.uninit(); + } + return this._countryCodes; + }, + + getRedirectURL(url) { + return `about:torconnect?redirect=${encodeURIComponent(url)}`; + }, + + // called from browser.js on browser startup, passed in either the user's homepage(s) + // or uris passed via command-line; we want to replace them with about:torconnect uris + // which redirect after bootstrapping + getURIsToLoad(uriVariant) { + // convert the object we get from browser.js + let uriStrings = (v => { + // an interop array + if (v instanceof Ci.nsIArray) { + // Transform the nsIArray of nsISupportsString's into a JS Array of + // JS strings. + return Array.from( + v.enumerate(Ci.nsISupportsString), + supportStr => supportStr.data + ); + // an interop string + } else if (v instanceof Ci.nsISupportsString) { + return [v.data]; + // a js string + } else if (typeof v === "string") { + return v.split("|"); + // a js array of js strings + } else if ( + Array.isArray(v) && + v.reduce((allStrings, entry) => { + return allStrings && typeof entry === "string"; + }, true) + ) { + return v; + } + // about:tor as safe fallback + console.log( + `TorConnect: getURIsToLoad() received unknown variant '${JSON.stringify( + v + )}'` + ); + return ["about:tor"]; + })(uriVariant); + + // will attempt to convert user-supplied string to a uri, fallback to about:tor if cannot convert + // to valid uri object + let uriStringToUri = uriString => { + const fixupFlags = Ci.nsIURIFixup.FIXUP_FLAG_NONE; + let uri = Services.uriFixup.getFixupURIInfo(uriString, fixupFlags) + .preferredURI; + return uri ? uri : Services.io.newURI("about:tor"); + }; + let uris = uriStrings.map(uriStringToUri); + + // assume we have a valid uri and generate an about:torconnect redirect uri + let redirectUrls = uris.map(uri => this.getRedirectURL(uri.spec)); + + console.log( + `TorConnect: Will load after bootstrap => [${uris + .map(uri => { + return uri.spec; + }) + .join(", ")}]` + ); + return redirectUrls; + }, + }; + retval.init(); + return retval; +})(); /* TorConnect */ diff --git a/browser/modules/TorProtocolService.jsm b/browser/modules/TorProtocolService.jsm new file mode 100644 index 000000000000..6a1d6b94fff7 --- /dev/null +++ b/browser/modules/TorProtocolService.jsm @@ -0,0 +1,510 @@ +// Copyright (c) 2021, The Tor Project, Inc. + +"use strict"; + +var EXPORTED_SYMBOLS = [ + "TorProtocolService", + "TorProcessStatus", + "TorTopics", + "TorBootstrapRequest", +]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const { setTimeout, clearTimeout } = ChromeUtils.import( + "resource://gre/modules/Timer.jsm" +); + +const { TorLauncherUtil } = ChromeUtils.import( + "resource://torlauncher/modules/tl-util.jsm" +); + +// see tl-process.js +const TorProcessStatus = Object.freeze({ + Unknown: 0, + Starting: 1, + Running: 2, + Exited: 3, +}); + +/* tor-launcher observer topics */ +const TorTopics = Object.freeze({ + BootstrapStatus: "TorBootstrapStatus", + BootstrapError: "TorBootstrapError", + ProcessExited: "TorProcessExited", + LogHasWarnOrErr: "TorLogHasWarnOrErr", +}); + +/* Browser observer topis */ +const BrowserTopics = Object.freeze({ + ProfileAfterChange: "profile-after-change", +}); + +var TorProtocolService = { + _TorLauncherProtocolService: null, + _TorProcessService: null, + + // maintain a map of tor settings set by Tor Browser so that we don't + // repeatedly set the same key/values over and over + // this map contains string keys to primitive or array values + _settingsCache: new Map(), + + init() { + Services.obs.addObserver(this, BrowserTopics.ProfileAfterChange); + }, + + observe(subject, topic, data) { + if (topic === BrowserTopics.ProfileAfterChange) { + // we have to delay init'ing this or else the crypto service inits too early without a profile + // which breaks the password manager + this._TorLauncherProtocolService = Cc[ + "@torproject.org/torlauncher-protocol-service;1" + ].getService(Ci.nsISupports).wrappedJSObject; + this._TorProcessService = Cc[ + "@torproject.org/torlauncher-process-service;1" + ].getService(Ci.nsISupports).wrappedJSObject; + Services.obs.removeObserver(this, topic); + } + }, + + _typeof(aValue) { + switch (typeof aValue) { + case "boolean": + return "boolean"; + case "string": + return "string"; + case "object": + if (aValue == null) { + return "null"; + } else if (Array.isArray(aValue)) { + return "array"; + } + return "object"; + } + return "unknown"; + }, + + _assertValidSettingKey(aSetting) { + // ensure the 'key' is a string + if (typeof aSetting != "string") { + throw new Error( + `Expected setting of type string but received ${typeof aSetting}` + ); + } + }, + + _assertValidSetting(aSetting, aValue) { + this._assertValidSettingKey(aSetting); + + const valueType = this._typeof(aValue); + switch (valueType) { + case "boolean": + case "string": + case "null": + return; + case "array": + for (const element of aValue) { + if (typeof element != "string") { + throw new Error( + `Setting '${aSetting}' array contains value of invalid type '${typeof element}'` + ); + } + } + return; + default: + throw new Error( + `Invalid object type received for setting '${aSetting}'` + ); + } + }, + + // takes a Map containing tor settings + // throws on error + async writeSettings(aSettingsObj) { + // only write settings that have changed + let newSettings = new Map(); + for (const [setting, value] of aSettingsObj) { + let saveSetting = false; + + // make sure we have valid data here + this._assertValidSetting(setting, value); + + if (!this._settingsCache.has(setting)) { + // no cached setting, so write + saveSetting = true; + } else { + const cachedValue = this._settingsCache.get(setting); + if (value != cachedValue) { + // compare arrays member-wise + if (Array.isArray(value) && Array.isArray(cachedValue)) { + if (value.length != cachedValue.length) { + saveSetting = true; + } else { + const arrayLength = value.length; + for (let i = 0; i < arrayLength; ++i) { + if (value[i] != cachedValue[i]) { + saveSetting = true; + break; + } + } + } + } else { + // some other different values + saveSetting = true; + } + } + } + + if (saveSetting) { + newSettings.set(setting, value); + } + } + + // only write if new setting to save + if (newSettings.size > 0) { + // convert settingsObject map to js object for torlauncher-protocol-service + let settingsObject = {}; + for (const [setting, value] of newSettings) { + settingsObject[setting] = value; + } + + let errorObject = {}; + if ( + !(await this._TorLauncherProtocolService.TorSetConfWithReply( + settingsObject, + errorObject + )) + ) { + throw new Error(errorObject.details); + } + + // save settings to cache after successfully writing to Tor + for (const [setting, value] of newSettings) { + this._settingsCache.set(setting, value); + } + } + }, + + async _readSetting(aSetting) { + this._assertValidSettingKey(aSetting); + let reply = await this._TorLauncherProtocolService.TorGetConf(aSetting); + if (this._TorLauncherProtocolService.TorCommandSucceeded(reply)) { + return reply.lineArray; + } + throw new Error(reply.lineArray.join("\n")); + }, + + async _readBoolSetting(aSetting) { + let lineArray = await this._readSetting(aSetting); + if (lineArray.length != 1) { + throw new Error( + `Expected an array with length 1 but received array of length ${lineArray.length}` + ); + } + + let retval = lineArray[0]; + switch (retval) { + case "0": + return false; + case "1": + return true; + default: + throw new Error(`Expected boolean (1 or 0) but received '${retval}'`); + } + }, + + async _readStringSetting(aSetting) { + let lineArray = await this._readSetting(aSetting); + if (lineArray.length != 1) { + throw new Error( + `Expected an array with length 1 but received array of length ${lineArray.length}` + ); + } + return lineArray[0]; + }, + + async _readStringArraySetting(aSetting) { + let lineArray = await this._readSetting(aSetting); + return lineArray; + }, + + async readBoolSetting(aSetting) { + let value = await this._readBoolSetting(aSetting); + this._settingsCache.set(aSetting, value); + return value; + }, + + async readStringSetting(aSetting) { + let value = await this._readStringSetting(aSetting); + this._settingsCache.set(aSetting, value); + return value; + }, + + async readStringArraySetting(aSetting) { + let value = await this._readStringArraySetting(aSetting); + this._settingsCache.set(aSetting, value); + return value; + }, + + // writes current tor settings to disk + async flushSettings() { + await this.sendCommand("SAVECONF"); + }, + + getLog(countObj) { + countObj = countObj || { value: 0 }; + let torLog = this._TorLauncherProtocolService.TorGetLog(countObj); + return torLog; + }, + + // true if we launched and control tor, false if using system tor + get ownsTorDaemon() { + return TorLauncherUtil.shouldStartAndOwnTor; + }, + + // Assumes `ownsTorDaemon` is true + isNetworkDisabled() { + const reply = TorProtocolService._TorLauncherProtocolService.TorGetConfBool( + "DisableNetwork", + true + ); + if ( + TorProtocolService._TorLauncherProtocolService.TorCommandSucceeded(reply) + ) { + return reply.retVal; + } + return true; + }, + + async enableNetwork() { + let settings = {}; + settings.DisableNetwork = false; + let errorObject = {}; + if ( + !(await this._TorLauncherProtocolService.TorSetConfWithReply( + settings, + errorObject + )) + ) { + throw new Error(errorObject.details); + } + }, + + async sendCommand(cmd) { + return this._TorLauncherProtocolService.TorSendCommand(cmd); + }, + + retrieveBootstrapStatus() { + return this._TorLauncherProtocolService.TorRetrieveBootstrapStatus(); + }, + + _GetSaveSettingsErrorMessage(aDetails) { + try { + return TorLauncherUtil.getSaveSettingsErrorMessage(aDetails); + } catch (e) { + console.log("GetSaveSettingsErrorMessage error", e); + return "Unexpected Error"; + } + }, + + async setConfWithReply(settings) { + let result = false; + const error = {}; + try { + result = await this._TorLauncherProtocolService.TorSetConfWithReply( + settings, + error + ); + } catch (e) { + console.log("TorSetConfWithReply error", e); + error.details = this._GetSaveSettingsErrorMessage(e.message); + } + return { result, error }; + }, + + isBootstrapDone() { + return this._TorProcessService.mIsBootstrapDone; + }, + + clearBootstrapError() { + return this._TorProcessService.TorClearBootstrapError(); + }, + + torBootstrapErrorOccurred() { + return this._TorProcessService.TorBootstrapErrorOccurred; + }, + + _torBootstrapDebugSetError() { + this._TorProcessService._TorSetBootstrapErrorForDebug(); + }, + + // Resolves to null if ok, or an error otherwise + async connect() { + const kTorConfKeyDisableNetwork = "DisableNetwork"; + const settings = {}; + settings[kTorConfKeyDisableNetwork] = false; + const { result, error } = await this.setConfWithReply(settings); + if (!result) { + return error; + } + try { + await this.sendCommand("SAVECONF"); + this.clearBootstrapError(); + this.retrieveBootstrapStatus(); + } catch (e) { + return error; + } + return null; + }, + + torLogHasWarnOrErr() { + return this._TorLauncherProtocolService.TorLogHasWarnOrErr; + }, + + async torStopBootstrap() { + // Tell tor to disable use of the network; this should stop the bootstrap + // process. + const kErrorPrefix = "Setting DisableNetwork=1 failed: "; + try { + let settings = {}; + settings.DisableNetwork = true; + const { result, error } = await this.setConfWithReply(settings); + if (!result) { + console.log( + `Error stopping bootstrap ${kErrorPrefix} ${error.details}` + ); + } + } catch (e) { + console.log(`Error stopping bootstrap ${kErrorPrefix} ${e}`); + } + this.retrieveBootstrapStatus(); + }, + + get torProcessStatus() { + if (this._TorProcessService) { + return this._TorProcessService.TorProcessStatus; + } + return TorProcessStatus.Unknown; + }, +}; +TorProtocolService.init(); + +// modeled after XMLHttpRequest +// nicely encapsulates the observer register/unregister logic +class TorBootstrapRequest { + constructor() { + // number of ms to wait before we abandon the bootstrap attempt + // a value of 0 implies we never wait + this.timeout = 0; + // callbacks for bootstrap process status updates + this.onbootstrapstatus = (progress, status) => {}; + this.onbootstrapcomplete = () => {}; + this.onbootstraperror = (message, details) => {}; + + // internal resolve() method for bootstrap + this._bootstrapPromiseResolve = null; + this._bootstrapPromise = null; + this._timeoutID = null; + } + + async observe(subject, topic, data) { + const obj = subject?.wrappedJSObject; + switch (topic) { + case TorTopics.BootstrapStatus: { + const progress = obj.PROGRESS; + const status = TorLauncherUtil.getLocalizedBootstrapStatus(obj, "TAG"); + if (this.onbootstrapstatus) { + this.onbootstrapstatus(progress, status); + } + if (progress === 100) { + if (this.onbootstrapcomplete) { + this.onbootstrapcomplete(); + } + this._bootstrapPromiseResolve(true); + clearTimeout(this._timeoutID); + } + + break; + } + case TorTopics.BootstrapError: { + // first stop our bootstrap timeout before handling the error + clearTimeout(this._timeoutID); + + await TorProtocolService.torStopBootstrap(); + + const message = obj.message; + const details = obj.details; + if (this.onbootstraperror) { + this.onbootstraperror(message, details); + } + this._bootstrapPromiseResolve(false); + break; + } + } + } + + // resolves 'true' if bootstrap succeeds, false otherwise + bootstrap() { + if (this._bootstrapPromise) { + return this._bootstrapPromise; + } + + this._bootstrapPromise = new Promise((resolve, reject) => { + this._bootstrapPromiseResolve = resolve; + + // register ourselves to listen for bootstrap events + Services.obs.addObserver(this, TorTopics.BootstrapStatus); + Services.obs.addObserver(this, TorTopics.BootstrapError); + + // optionally cancel bootstrap after a given timeout + if (this.timeout > 0) { + this._timeoutID = setTimeout(async () => { + await TorProtocolService.torStopBootstrap(); + if (this.onbootstraperror) { + this.onbootstraperror( + "Tor Bootstrap process timed out", + `Bootstrap attempt abandoned after waiting ${this.timeout} ms` + ); + } + this._bootstrapPromiseResolve(false); + }, this.timeout); + } + + // wait for bootstrapping to begin and maybe handle error + TorProtocolService.connect() + .then(async err => { + if (!err) { + return; + } + + clearTimeout(this._timeoutID); + await TorProtocolService.torStopBootstrap(); + + const message = err.message; + const details = err.details; + if (this.onbootstraperror) { + this.onbootstraperror(message, details); + } + this._bootstrapPromiseResolve(false); + }) + .catch(err => { + // Currently, TorProtocolService.connect() should never throw + reject(err); + }); + }).finally(() => { + // and remove ourselves once bootstrap is resolved + Services.obs.removeObserver(this, TorTopics.BootstrapStatus); + Services.obs.removeObserver(this, TorTopics.BootstrapError); + }); + + return this._bootstrapPromise; + } + + async cancel() { + clearTimeout(this._timeoutID); + + await TorProtocolService.torStopBootstrap(); + + this._bootstrapPromiseResolve(false); + } +} diff --git a/browser/modules/TorSettings.jsm b/browser/modules/TorSettings.jsm new file mode 100644 index 000000000000..57a2a80c7d3f --- /dev/null +++ b/browser/modules/TorSettings.jsm @@ -0,0 +1,788 @@ +"use strict"; + +var EXPORTED_SYMBOLS = [ + "TorSettings", + "TorSettingsTopics", + "TorSettingsData", + "TorBridgeSource", + "TorBuiltinBridgeTypes", + "TorProxyType", +]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const { TorProtocolService, TorProcessStatus } = ChromeUtils.import( + "resource:///modules/TorProtocolService.jsm" +); + +/* Browser observer topics */ +const BrowserTopics = Object.freeze({ + ProfileAfterChange: "profile-after-change", +}); + +/* tor-launcher observer topics */ +const TorTopics = Object.freeze({ + ProcessIsReady: "TorProcessIsReady", +}); + +/* TorSettings observer topics */ +const TorSettingsTopics = Object.freeze({ + Ready: "torsettings:ready", + SettingChanged: "torsettings:setting-changed", +}); + +/* TorSettings observer data (for SettingChanged topic) */ +const TorSettingsData = Object.freeze({ + QuickStartEnabled: "torsettings:quickstart_enabled", +}); + +/* Prefs used to store settings in TorBrowser prefs */ +const TorSettingsPrefs = Object.freeze({ + /* bool: are we pulling tor settings from the preferences */ + enabled: "torbrowser.settings.enabled", + quickstart: { + /* bool: does tor connect automatically on launch */ + enabled: "torbrowser.settings.quickstart.enabled", + }, + bridges: { + /* bool: does tor use bridges */ + enabled: "torbrowser.settings.bridges.enabled", + /* int: -1=invalid|0=builtin|1=bridge_db|2=user_provided */ + source: "torbrowser.settings.bridges.source", + /* string: obfs4|meek_azure|snowflake|etc */ + builtin_type: "torbrowser.settings.bridges.builtin_type", + /* preference branch: each child branch should be a bridge string */ + bridge_strings: "torbrowser.settings.bridges.bridge_strings", + }, + proxy: { + /* bool: does tor use a proxy */ + enabled: "torbrowser.settings.proxy.enabled", + /* -1=invalid|0=socks4,1=socks5,2=https */ + type: "torbrowser.settings.proxy.type", + /* string: proxy server address */ + address: "torbrowser.settings.proxy.address", + /* int: [1,65535], proxy port */ + port: "torbrowser.settings.proxy.port", + /* string: username */ + username: "torbrowser.settings.proxy.username", + /* string: password */ + password: "torbrowser.settings.proxy.password", + }, + firewall: { + /* bool: does tor have a port allow list */ + enabled: "torbrowser.settings.firewall.enabled", + /* string: comma-delimitted list of port numbers */ + allowed_ports: "torbrowser.settings.firewall.allowed_ports", + }, +}); + +/* Legacy tor-launcher prefs and pref branches*/ +const TorLauncherPrefs = Object.freeze({ + quickstart: "extensions.torlauncher.quickstart", + default_bridge_type: "extensions.torlauncher.default_bridge_type", + default_bridge: "extensions.torlauncher.default_bridge.", + default_bridge_recommended_type: + "extensions.torlauncher.default_bridge_recommended_type", + bridgedb_bridge: "extensions.torlauncher.bridgedb_bridge.", +}); + +/* 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", +}); + +const TorBridgeSource = Object.freeze({ + Invalid: -1, + BuiltIn: 0, + BridgeDB: 1, + UserProvided: 2, +}); + +const TorProxyType = Object.freeze({ + Invalid: -1, + Socks4: 0, + Socks5: 1, + HTTPS: 2, +}); + +const TorBuiltinBridgeTypes = Object.freeze( + (() => { + const bridgeListBranch = Services.prefs.getBranch( + TorLauncherPrefs.default_bridge + ); + const bridgePrefs = bridgeListBranch.getChildList(""); + + // an unordered set for shoving bridge types into + const bridgeTypes = new Set(); + // look for keys ending in ".N" and treat string before that as the bridge type + const pattern = /.[0-9]+$/; + for (const key of bridgePrefs) { + const offset = key.search(pattern); + if (offset != -1) { + const bt = key.substring(0, offset); + bridgeTypes.add(bt); + } + } + + // recommended bridge type goes first in the list + const recommendedBridgeType = Services.prefs.getCharPref( + TorLauncherPrefs.default_bridge_recommended_type, + null + ); + + const retval = []; + if (recommendedBridgeType && bridgeTypes.has(recommendedBridgeType)) { + retval.push(recommendedBridgeType); + } + + for (const bridgeType of bridgeTypes.values()) { + if (bridgeType != recommendedBridgeType) { + retval.push(bridgeType); + } + } + return retval; + })() +); + +/* Parsing Methods */ + +// expects a string representation of an integer from 1 to 65535 +const parsePort = function(aPort) { + // ensure port string is a valid positive integer + const validIntRegex = /^[0-9]+$/; + if (!validIntRegex.test(aPort)) { + return 0; + } + + // ensure port value is on valid range + const port = Number.parseInt(aPort); + if (port < 1 || port > 65535) { + return 0; + } + + return port; +}; + +// expects a '\n' or '\r\n' delimited bridge string, which we split and trim +// each bridge string can also optionally have 'bridge' at the beginning ie: +// bridge $(type) $(address):$(port) $(certificate) +// we strip out the 'bridge' prefix here +const parseBridgeStrings = function(aBridgeStrings) { + // replace carriage returns ('\r') with new lines ('\n') + aBridgeStrings = aBridgeStrings.replace(/\r/g, "\n"); + // then replace contiguous new lines ('\n') with a single one + aBridgeStrings = aBridgeStrings.replace(/[\n]+/g, "\n"); + + // split on the newline and for each bridge string: trim, remove starting 'bridge' string + // finally discard entries that are empty strings; empty strings could occur if we receive + // a new line containing only whitespace + const splitStrings = aBridgeStrings.split("\n"); + return splitStrings + .map(val => val.trim().replace(/^bridge\s+/i, "")) + .filter(bridgeString => bridgeString != ""); +}; + +// expecting a ',' delimited list of ints with possible white space between +// returns an array of ints +const parsePortList = function(aPortListString) { + const splitStrings = aPortListString.split(","); + // parse and remove duplicates + const portSet = new Set(splitStrings.map(val => parsePort(val.trim()))); + // parsePort returns 0 for failed parses, so remove 0 from list + portSet.delete(0); + return Array.from(portSet); +}; + +const getBuiltinBridgeStrings = function(builtinType) { + if (!builtinType) { + return []; + } + + const bridgeBranch = Services.prefs.getBranch( + TorLauncherPrefs.default_bridge + ); + const bridgeBranchPrefs = bridgeBranch.getChildList(""); + const retval = []; + + // regex matches against strings ending in ".N" where N is a positive integer + const pattern = /.[0-9]+$/; + for (const key of bridgeBranchPrefs) { + // verify the location of the match is the correct offset required for aBridgeType + // to fit, and that the string begins with aBridgeType + if ( + key.search(pattern) == builtinType.length && + key.startsWith(builtinType) + ) { + const bridgeStr = bridgeBranch.getCharPref(key); + retval.push(bridgeStr); + } + } + + // shuffle so that Tor Browser users don't all try the built-in bridges in the same order + arrayShuffle(retval); + + return retval; +}; + +/* Helper methods */ + +const arrayShuffle = function(array) { + // fisher-yates shuffle + for (let i = array.length - 1; i > 0; --i) { + // number n such that 0.0 <= n < 1.0 + const n = Math.random(); + // integer j such that 0 <= j <= i + const j = Math.floor(n * (i + 1)); + + // swap values at indices i and j + const tmp = array[i]; + array[i] = array[j]; + array[j] = tmp; + } +}; + +const arrayCopy = function(array) { + return [].concat(array); +}; + +/* TorSettings module */ + +const TorSettings = (() => { + const self = { + _settings: null, + + // tor daemon related settings + defaultSettings() { + const settings = { + quickstart: { + enabled: false, + }, + bridges: { + enabled: false, + source: TorBridgeSource.Invalid, + builtin_type: null, + bridge_strings: [], + }, + proxy: { + enabled: false, + type: TorProxyType.Invalid, + address: null, + port: 0, + username: null, + password: null, + }, + firewall: { + enabled: false, + allowed_ports: [], + }, + }; + return settings; + }, + + /* load or init our settings, and register observers */ + init() { + if (TorProtocolService.ownsTorDaemon) { + // if the settings branch exists, load settings from prefs + if (Services.prefs.getBoolPref(TorSettingsPrefs.enabled, false)) { + this.loadFromPrefs(); + } else { + // otherwise load defaults + this._settings = this.defaultSettings(); + } + Services.obs.addObserver(this, BrowserTopics.ProfileAfterChange); + Services.obs.addObserver(this, TorTopics.ProcessIsReady); + } + }, + + /* wait for relevant life-cycle events to apply saved settings */ + async observe(subject, topic, data) { + console.log(`TorSettings: Observed ${topic}`); + + // once the tor daemon is ready, we need to apply our settings + const handleProcessReady = async () => { + // push down settings to tor + await this.applySettings(); + console.log("TorSettings: Ready"); + Services.obs.notifyObservers(null, TorSettingsTopics.Ready); + }; + + switch (topic) { + case BrowserTopics.ProfileAfterChange: + Services.obs.removeObserver(this, BrowserTopics.ProfileAfterChange); + if (TorProtocolService.torProcessStatus == TorProcessStatus.Running) { + await handleProcessReady(); + } + break; + case TorTopics.ProcessIsReady: + Services.obs.removeObserver(this, TorTopics.ProcessIsReady); + await handleProcessReady(); + break; + } + }, + + // load our settings from prefs + loadFromPrefs() { + console.log("TorSettings: loadFromPrefs()"); + + const settings = this.defaultSettings(); + + /* Quickstart */ + settings.quickstart.enabled = Services.prefs.getBoolPref( + TorSettingsPrefs.quickstart.enabled + ); + /* Bridges */ + settings.bridges.enabled = Services.prefs.getBoolPref( + TorSettingsPrefs.bridges.enabled + ); + settings.bridges.source = Services.prefs.getIntPref( + TorSettingsPrefs.bridges.source, + TorBridgeSource.Invalid + ); + if (settings.bridges.source == TorBridgeSource.BuiltIn) { + const builtinType = Services.prefs.getStringPref( + TorSettingsPrefs.bridges.builtin_type + ); + settings.bridges.builtin_type = builtinType; + settings.bridges.bridge_strings = getBuiltinBridgeStrings(builtinType); + if (!settings.bridges.bridge_strings.length) { + // in this case the user is using a builtin bridge that is no longer supported, + // reset to settings to default values + settings.bridges.source = TorBridgeSource.Invalid; + settings.bridges.builtin_type = null; + } + } else { + settings.bridges.bridge_strings = []; + const bridgeBranchPrefs = Services.prefs + .getBranch(TorSettingsPrefs.bridges.bridge_strings) + .getChildList(""); + bridgeBranchPrefs.forEach(pref => { + const bridgeString = Services.prefs.getStringPref( + `${TorSettingsPrefs.bridges.bridge_strings}${pref}` + ); + settings.bridges.bridge_strings.push(bridgeString); + }); + } + /* Proxy */ + settings.proxy.enabled = Services.prefs.getBoolPref( + TorSettingsPrefs.proxy.enabled + ); + if (settings.proxy.enabled) { + settings.proxy.type = Services.prefs.getIntPref( + TorSettingsPrefs.proxy.type + ); + settings.proxy.address = Services.prefs.getStringPref( + TorSettingsPrefs.proxy.address + ); + settings.proxy.port = Services.prefs.getIntPref( + TorSettingsPrefs.proxy.port + ); + settings.proxy.username = Services.prefs.getStringPref( + TorSettingsPrefs.proxy.username + ); + settings.proxy.password = Services.prefs.getStringPref( + TorSettingsPrefs.proxy.password + ); + } else { + settings.proxy.type = TorProxyType.Invalid; + settings.proxy.address = null; + settings.proxy.port = 0; + settings.proxy.username = null; + settings.proxy.password = null; + } + + /* Firewall */ + settings.firewall.enabled = Services.prefs.getBoolPref( + TorSettingsPrefs.firewall.enabled + ); + if (settings.firewall.enabled) { + const portList = Services.prefs.getStringPref( + TorSettingsPrefs.firewall.allowed_ports + ); + settings.firewall.allowed_ports = parsePortList(portList); + } else { + settings.firewall.allowed_ports = 0; + } + + this._settings = settings; + + return this; + }, + + // save our settings to prefs + saveToPrefs() { + console.log("TorSettings: saveToPrefs()"); + + const settings = this._settings; + + /* Quickstart */ + Services.prefs.setBoolPref( + TorSettingsPrefs.quickstart.enabled, + settings.quickstart.enabled + ); + /* Bridges */ + Services.prefs.setBoolPref( + TorSettingsPrefs.bridges.enabled, + settings.bridges.enabled + ); + Services.prefs.setIntPref( + TorSettingsPrefs.bridges.source, + settings.bridges.source + ); + Services.prefs.setStringPref( + TorSettingsPrefs.bridges.builtin_type, + settings.bridges.builtin_type + ); + // erase existing bridge strings + const bridgeBranchPrefs = Services.prefs + .getBranch(TorSettingsPrefs.bridges.bridge_strings) + .getChildList(""); + bridgeBranchPrefs.forEach(pref => { + Services.prefs.clearUserPref( + `${TorSettingsPrefs.bridges.bridge_strings}${pref}` + ); + }); + // write new ones + if (settings.bridges.source !== TorBridgeSource.BuiltIn) { + settings.bridges.bridge_strings.forEach((string, index) => { + Services.prefs.setStringPref( + `${TorSettingsPrefs.bridges.bridge_strings}.${index}`, + string + ); + }); + } + /* Proxy */ + Services.prefs.setBoolPref( + TorSettingsPrefs.proxy.enabled, + settings.proxy.enabled + ); + if (settings.proxy.enabled) { + Services.prefs.setIntPref( + TorSettingsPrefs.proxy.type, + settings.proxy.type + ); + Services.prefs.setStringPref( + TorSettingsPrefs.proxy.address, + settings.proxy.address + ); + Services.prefs.setIntPref( + TorSettingsPrefs.proxy.port, + settings.proxy.port + ); + Services.prefs.setStringPref( + TorSettingsPrefs.proxy.username, + settings.proxy.username + ); + Services.prefs.setStringPref( + TorSettingsPrefs.proxy.password, + settings.proxy.password + ); + } else { + Services.prefs.clearUserPref(TorSettingsPrefs.proxy.type); + Services.prefs.clearUserPref(TorSettingsPrefs.proxy.address); + Services.prefs.clearUserPref(TorSettingsPrefs.proxy.port); + Services.prefs.clearUserPref(TorSettingsPrefs.proxy.username); + Services.prefs.clearUserPref(TorSettingsPrefs.proxy.password); + } + /* Firewall */ + Services.prefs.setBoolPref( + TorSettingsPrefs.firewall.enabled, + settings.firewall.enabled + ); + if (settings.firewall.enabled) { + Services.prefs.setStringPref( + TorSettingsPrefs.firewall.allowed_ports, + settings.firewall.allowed_ports.join(",") + ); + } else { + Services.prefs.clearUserPref(TorSettingsPrefs.firewall.allowed_ports); + } + + // all tor settings now stored in prefs :) + Services.prefs.setBoolPref(TorSettingsPrefs.enabled, true); + + return this; + }, + + // push our settings down to the tor daemon + async applySettings() { + console.log("TorSettings: applySettings()"); + const settings = this._settings; + const settingsMap = new Map(); + + /* Bridges */ + const haveBridges = + settings.bridges.enabled && !!settings.bridges.bridge_strings.length; + settingsMap.set(TorConfigKeys.useBridges, haveBridges); + if (haveBridges) { + settingsMap.set( + TorConfigKeys.bridgeList, + settings.bridges.bridge_strings + ); + } else { + settingsMap.set(TorConfigKeys.bridgeList, null); + } + + /* Proxy */ + settingsMap.set(TorConfigKeys.socks4Proxy, null); + settingsMap.set(TorConfigKeys.socks5Proxy, null); + settingsMap.set(TorConfigKeys.socks5ProxyUsername, null); + settingsMap.set(TorConfigKeys.socks5ProxyPassword, null); + settingsMap.set(TorConfigKeys.httpsProxy, null); + settingsMap.set(TorConfigKeys.httpsProxyAuthenticator, null); + if (settings.proxy.enabled) { + const address = settings.proxy.address; + const port = settings.proxy.port; + const username = settings.proxy.username; + const password = settings.proxy.password; + + switch (settings.proxy.type) { + case TorProxyType.Socks4: + settingsMap.set(TorConfigKeys.socks4Proxy, `${address}:${port}`); + break; + case TorProxyType.Socks5: + settingsMap.set(TorConfigKeys.socks5Proxy, `${address}:${port}`); + settingsMap.set(TorConfigKeys.socks5ProxyUsername, username); + settingsMap.set(TorConfigKeys.socks5ProxyPassword, password); + break; + case TorProxyType.HTTPS: + settingsMap.set(TorConfigKeys.httpsProxy, `${address}:${port}`); + settingsMap.set( + TorConfigKeys.httpsProxyAuthenticator, + `${username}:${password}` + ); + break; + } + } + + /* Firewall */ + if (settings.firewall.enabled) { + const reachableAddresses = settings.firewall.allowed_ports + .map(port => `*:${port}`) + .join(","); + settingsMap.set(TorConfigKeys.reachableAddresses, reachableAddresses); + } else { + settingsMap.set(TorConfigKeys.reachableAddresses, null); + } + + /* Push to Tor */ + await TorProtocolService.writeSettings(settingsMap); + + return this; + }, + + // set all of our settings at once from a settings object + setSettings(settings) { + console.log("TorSettings: setSettings()"); + const backup = this.getSettings(); + + try { + this._settings.bridges.enabled = !!settings.bridges.enabled; + this._settings.bridges.source = settings.bridges.source; + switch (settings.bridges.source) { + case TorBridgeSource.BridgeDB: + case TorBridgeSource.UserProvided: + this._settings.bridges.bridge_strings = + settings.bridges.bridge_strings; + break; + case TorBridgeSource.BuiltIn: { + this._settings.bridges.builtin_type = settings.bridges.builtin_type; + settings.bridges.bridge_strings = getBuiltinBridgeStrings( + settings.bridges.builtin_type + ); + if ( + !settings.bridges.bridge_strings.length && + settings.bridges.enabled + ) { + throw new Error( + `No available builtin bridges of type ${settings.bridges.builtin_type}` + ); + } + this._settings.bridges.bridge_strings = + settings.bridges.bridge_strings; + break; + } + case TorBridgeSource.Invalid: + break; + default: + if (settings.bridges.enabled) { + throw new Error( + `Bridge source '${settings.source}' is not a valid source` + ); + } + break; + } + + // TODO: proxy and firewall + } catch (ex) { + this._settings = backup; + console.log(`TorSettings: setSettings failed => ${ex.message}`); + } + + console.log("TorSettings: setSettings result"); + console.log(this._settings); + }, + + // get a copy of all our settings + getSettings() { + console.log("TorSettings: getSettings()"); + // TODO: replace with structuredClone someday (post esr94): https://developer.mozilla.org/en-US/docs/Web/API/structuredClone + return JSON.parse(JSON.stringify(this._settings)); + }, + + /* Getters and Setters */ + + // Quickstart + get quickstart() { + return { + get enabled() { + return self._settings.quickstart.enabled; + }, + set enabled(val) { + if (val != self._settings.quickstart.enabled) { + self._settings.quickstart.enabled = val; + Services.obs.notifyObservers( + { value: val }, + TorSettingsTopics.SettingChanged, + TorSettingsData.QuickStartEnabled + ); + } + }, + }; + }, + + // Bridges + get bridges() { + return { + get enabled() { + return self._settings.bridges.enabled; + }, + set enabled(val) { + self._settings.bridges.enabled = val; + }, + get source() { + return self._settings.bridges.source; + }, + set source(val) { + self._settings.bridges.source = val; + }, + get builtin_type() { + return self._settings.bridges.builtin_type; + }, + set builtin_type(val) { + const bridgeStrings = getBuiltinBridgeStrings(val); + if (bridgeStrings.length) { + self._settings.bridges.builtin_type = val; + self._settings.bridges.bridge_strings = bridgeStrings; + } else { + self._settings.bridges.builtin_type = ""; + if (self._settings.bridges.source === TorBridgeSource.BuiltIn) { + self._settings.bridges.source = TorBridgeSource.Invalid; + } + } + }, + get bridge_strings() { + return arrayCopy(self._settings.bridges.bridge_strings); + }, + set bridge_strings(val) { + self._settings.bridges.bridge_strings = parseBridgeStrings(val); + }, + }; + }, + + // Proxy + get proxy() { + return { + get enabled() { + return self._settings.proxy.enabled; + }, + set enabled(val) { + self._settings.proxy.enabled = val; + // reset proxy settings + self._settings.proxy.type = TorProxyType.Invalid; + self._settings.proxy.address = null; + self._settings.proxy.port = 0; + self._settings.proxy.username = null; + self._settings.proxy.password = null; + }, + get type() { + return self._settings.proxy.type; + }, + set type(val) { + self._settings.proxy.type = val; + }, + get address() { + return self._settings.proxy.address; + }, + set address(val) { + self._settings.proxy.address = val; + }, + get port() { + return arrayCopy(self._settings.proxy.port); + }, + set port(val) { + self._settings.proxy.port = parsePort(val); + }, + get username() { + return self._settings.proxy.username; + }, + set username(val) { + self._settings.proxy.username = val; + }, + get password() { + return self._settings.proxy.password; + }, + set password(val) { + self._settings.proxy.password = val; + }, + get uri() { + switch (this.type) { + case TorProxyType.Socks4: + return `socks4a://${this.address}:${this.port}`; + case TorProxyType.Socks5: + if (this.username) { + return `socks5://${this.username}:${this.password}@${this.address}:${this.port}`; + } + return `socks5://${this.address}:${this.port}`; + case TorProxyType.HTTPS: + if (this._proxyUsername) { + return `http://$%7Bthis.username%7D:$%7Bthis.password%7D@$%7Bthis.address%7D:$%7Bthi...; + } + return `http://$%7Bthis.address%7D:$%7Bthis.port%7D%60; + } + return null; + }, + }; + }, + + // Firewall + get firewall() { + return { + get enabled() { + return self._settings.firewall.enabled; + }, + set enabled(val) { + self._settings.firewall.enabled = val; + // reset firewall settings + self._settings.firewall.allowed_ports = []; + }, + get allowed_ports() { + return self._settings.firewall.allowed_ports; + }, + set allowed_ports(val) { + self._settings.firewall.allowed_ports = parsePortList(val); + }, + }; + }, + }; + self.init(); + return self; +})(); diff --git a/browser/modules/moz.build b/browser/modules/moz.build index dc73d9fbccdd..b29d496879d6 100644 --- a/browser/modules/moz.build +++ b/browser/modules/moz.build @@ -121,6 +121,7 @@ EXTRA_JS_MODULES += [ "AboutNewTab.jsm", "AppUpdater.jsm", "AsyncTabSwitcher.jsm", + "BridgeDB.jsm", "BrowserUIUtils.jsm", "BrowserUsageTelemetry.jsm", "BrowserWindowTracker.jsm", @@ -131,6 +132,7 @@ EXTRA_JS_MODULES += [ "FaviconLoader.jsm", "HomePage.jsm", "LaterRun.jsm", + 'Moat.jsm', "NewTabPagePreloading.jsm", "OpenInTabsUtils.jsm", "PageActions.jsm", @@ -144,6 +146,8 @@ EXTRA_JS_MODULES += [ "SitePermissions.jsm", "TabsList.jsm", "TabUnloader.jsm", + "TorProtocolService.jsm", + "TorSettings.jsm", "TransientPrefs.jsm", "webrtcUI.jsm", "ZoomUI.jsm", diff --git a/toolkit/components/processsingleton/MainProcessSingleton.jsm b/toolkit/components/processsingleton/MainProcessSingleton.jsm index 4f800b93fbce..28a16b732172 100644 --- a/toolkit/components/processsingleton/MainProcessSingleton.jsm +++ b/toolkit/components/processsingleton/MainProcessSingleton.jsm @@ -20,6 +20,9 @@ MainProcessSingleton.prototype = { // Imported for side-effects. ChromeUtils.import("resource://gre/modules/CustomElementsListener.jsm");
+ // FIXME: Is this import really necessary? + ChromeUtils.import("resource:///modules/TorSettings.jsm"); + Services.ppmm.loadProcessScript( "chrome://global/content/process-content.js", true