tor-browser

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

commit 7def59d740ef0a4807e20c47928d3cca42b41c18
parent f939b6229b83e880e4c638318f173acf7d49b0cc
Author: Richard Pospesel <richard@torproject.org>
Date:   Fri,  6 Aug 2021 16:39:03 +0200

TB 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

Diffstat:
M.prettierignore | 2++
Mbrowser/app/profile/000-tor-browser.js | 3+++
Meslint-ignores.config.mjs | 2++
Mtoolkit/components/tor-launcher/TorStartupService.sys.mjs | 7+++++++
Mtoolkit/content/jar.mn | 7+++++++
Atoolkit/content/moat_countries_dev_build.json | 7+++++++
Atoolkit/content/pt_config.json | 27+++++++++++++++++++++++++++
Atoolkit/modules/BridgeDB.sys.mjs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/modules/DomainFrontedRequests.sys.mjs | 616+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/modules/Moat.sys.mjs | 392+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/modules/TorConnect.sys.mjs | 1633+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atoolkit/modules/TorSettings.sys.mjs | 1570+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtoolkit/modules/moz.build | 5+++++
13 files changed, 4366 insertions(+), 0 deletions(-)

diff --git a/.prettierignore b/.prettierignore @@ -1799,3 +1799,5 @@ xpcom/idl-parser/xpidl/fixtures/xpctest.d.json browser/app/profile/001-base-profile.js browser/app/profile/000-tor-browser.js mobile/android/app/000-tor-browser-android.js +toolkit/content/pt_config.json +toolkit/content/moat_countries_dev_build.json diff --git a/browser/app/profile/000-tor-browser.js b/browser/app/profile/000-tor-browser.js @@ -134,3 +134,6 @@ pref("extensions.torlauncher.moat_service", "https://bridges.torproject.org/moat pref("browser.tor_provider.log_level", "Warn"); pref("browser.tor_provider.cp_log_level", "Warn"); pref("lox.log_level", "Warn"); +pref("torbrowser.bootstrap.log_level", "Info"); +pref("browser.torsettings.log_level", "Warn"); +pref("browser.torMoat.loglevel", "Warn"); diff --git a/eslint-ignores.config.mjs b/eslint-ignores.config.mjs @@ -316,4 +316,6 @@ export default [ "browser/app/profile/001-base-profile.js", "browser/app/profile/000-tor-browser.js", "mobile/android/app/000-tor-browser-android.js", + "toolkit/content/pt_config.json", + "toolkit/content/moat_contries_dev_build.json", ]; diff --git a/toolkit/components/tor-launcher/TorStartupService.sys.mjs b/toolkit/components/tor-launcher/TorStartupService.sys.mjs @@ -3,8 +3,10 @@ const lazy = {}; // We will use the modules only when the profile is loaded, so prefer lazy // loading ChromeUtils.defineESModuleGetters(lazy, { + TorConnect: "resource://gre/modules/TorConnect.sys.mjs", TorLauncherUtil: "resource://gre/modules/TorLauncherUtil.sys.mjs", TorProviderBuilder: "resource://gre/modules/TorProviderBuilder.sys.mjs", + TorSettings: "resource://gre/modules/TorSettings.sys.mjs", }); /* Browser observer topis */ @@ -33,11 +35,15 @@ export class TorStartupService { #init() { Services.obs.addObserver(this, BrowserTopics.QuitApplicationGranted); + lazy.TorSettings.init(); + // Theoretically, build() is expected to await the initialization of the // provider, and anything needing the Tor Provider should be able to just // await on TorProviderBuilder.build(). lazy.TorProviderBuilder.init(); + lazy.TorConnect.init(); + gInited = true; } @@ -46,5 +52,6 @@ export class TorStartupService { lazy.TorProviderBuilder.uninit(); lazy.TorLauncherUtil.cleanupTempDirectories(); + lazy.TorSettings.uninit(); } } diff --git a/toolkit/content/jar.mn b/toolkit/content/jar.mn @@ -216,3 +216,10 @@ toolkit.jar: content/global/vendor/react-transition-group.js (vendor/react/react-transition-group.js) content/global/vendor/redux.js (vendor/react/redux.js) content/global/vendor/react-redux.js (vendor/react/react-redux.js) + + # The pt_config.json content should be replaced in the omni.ja in + # tor-browser-build. See tor-browser#42343. + content/global/pt_config.json (pt_config.json) + # The moat_countries.json content should be replaced in the omni.ja in + # tor-browser-build. See tor-browser#43463. + content/global/moat_countries.json (moat_countries_dev_build.json) diff --git a/toolkit/content/moat_countries_dev_build.json b/toolkit/content/moat_countries_dev_build.json @@ -0,0 +1,7 @@ +[ + { + "_comment1": "Used for dev build, replaced for release builds in tor-browser-build.", + "_comment2": "List is taken from tpo/anti-censorship/rdsys-admin 810fb24b:conf/circumvention.json and filtered with `jq -c keys`." + }, + "by","cn","eg","hk","ir","mm","ru","tm" +] diff --git a/toolkit/content/pt_config.json b/toolkit/content/pt_config.json @@ -0,0 +1,27 @@ +{ + "_comment": "Used for dev build, replaced for release builds in tor-browser-build. This file is copied from tor-browser-build cf5a3f623f94cf5bc093db61fe64f9b38b03fce0:projects/tor-expert-bundle/pt_config.json", + "recommendedDefault" : "obfs4", + "pluggableTransports" : { + "lyrebird" : "ClientTransportPlugin meek_lite,obfs2,obfs3,obfs4,scramblesuit,webtunnel exec ${pt_path}lyrebird${pt_extension}", + "snowflake": "ClientTransportPlugin snowflake exec ${pt_path}lyrebird${pt_extension}", + "conjure" : "ClientTransportPlugin conjure exec ${pt_path}conjure-client${pt_extension} -registerURL https://registration.refraction.network/api" + }, + "bridges" : { + "meek" : [ + "meek_lite 192.0.2.20:80 url=https://1603026938.rsc.cdn77.org front=www.phpmyadmin.net utls=HelloRandomizedALPN" + ], + "obfs4" : [ + "obfs4 37.218.245.14:38224 D9A82D2F9C2F65A18407B1D2B764F130847F8B5D cert=bjRaMrr1BRiAW8IE9U5z27fQaYgOhX1UCmOpg2pFpoMvo6ZgQMzLsaTzzQNTlm7hNcb+Sg iat-mode=0", + "obfs4 209.148.46.65:443 74FAD13168806246602538555B5521A0383A1875 cert=ssH+9rP8dG2NLDN2XuFw63hIO/9MNNinLmxQDpVa+7kTOa9/m+tGWT1SmSYpQ9uTBGa6Hw iat-mode=0", + "obfs4 146.57.248.225:22 10A6CD36A537FCE513A322361547444B393989F0 cert=K1gDtDAIcUfeLqbstggjIw2rtgIKqdIhUlHp82XRqNSq/mtAjp1BIC9vHKJ2FAEpGssTPw iat-mode=0", + "obfs4 45.145.95.6:27015 C5B7CD6946FF10C5B3E89691A7D3F2C122D2117C cert=TD7PbUO0/0k6xYHMPW3vJxICfkMZNdkRrb63Zhl5j9dW3iRGiCx0A7mPhe5T2EDzQ35+Zw iat-mode=0", + "obfs4 51.222.13.177:80 5EDAC3B810E12B01F6FD8050D2FD3E277B289A08 cert=2uplIpLQ0q9+0qMFrK5pkaYRDOe460LL9WHBvatgkuRr/SL31wBOEupaMMJ6koRE6Ld0ew iat-mode=0", + "obfs4 212.83.43.95:443 BFE712113A72899AD685764B211FACD30FF52C31 cert=ayq0XzCwhpdysn5o0EyDUbmSOx3X/oTEbzDMvczHOdBJKlvIdHHLJGkZARtT4dcBFArPPg iat-mode=1", + "obfs4 212.83.43.74:443 39562501228A4D5E27FCA4C0C81A01EE23AE3EE4 cert=PBwr+S8JTVZo6MPdHnkTwXJPILWADLqfMGoVvhZClMq/Urndyd42BwX9YFJHZnBB3H0XCw iat-mode=1" + ], + "snowflake" : [ + "snowflake 192.0.2.3:80 2B280B23E1107BB62ABFC40DDCC8824814F80A72 fingerprint=2B280B23E1107BB62ABFC40DDCC8824814F80A72 url=https://1098762253.rsc.cdn77.org/ fronts=app.datapacket.com,www.datapacket.com ice=stun:stun.epygi.com:3478,stun:stun.uls.co.za:3478,stun:stun.voipgate.com:3478,stun:stun.mixvoip.com:3478,stun:stun.nextcloud.com:3478,stun:stun.bethesda.net:3478,stun:stun.nextcloud.com:443 utls-imitate=hellorandomizedalpn", + "snowflake 192.0.2.4:80 8838024498816A039FCBBAB14E6F40A0843051FA fingerprint=8838024498816A039FCBBAB14E6F40A0843051FA url=https://1098762253.rsc.cdn77.org/ fronts=app.datapacket.com,www.datapacket.com ice=stun:stun.epygi.com:3478,stun:stun.uls.co.za:3478,stun:stun.voipgate.com:3478,stun:stun.mixvoip.com:3478,stun:stun.nextcloud.com:3478,stun:stun.bethesda.net:3478,stun:stun.nextcloud.com:443 utls-imitate=hellorandomizedalpn" + ] + } +} diff --git a/toolkit/modules/BridgeDB.sys.mjs b/toolkit/modules/BridgeDB.sys.mjs @@ -0,0 +1,95 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + MoatRPC: "resource://gre/modules/Moat.sys.mjs", +}); + +export var BridgeDB = { + _moatRPC: null, + _challenge: null, + _image: null, + _bridges: null, + /** + * A collection of controllers to abort any ongoing Moat requests if the + * dialog is closed. + * + * NOTE: We do not expect this set to ever contain more than one instance. + * However the public API has no assurances to prevent multiple calls. + * + * @type {Set<AbortController>} + */ + _moatAbortControllers: new Set(), + + get currentCaptchaImage() { + return this._image; + }, + + get currentBridges() { + return this._bridges; + }, + + async submitCaptchaGuess(solution) { + if (!this._moatRPC) { + this._moatRPC = new lazy.MoatRPC(); + await this._moatRPC.init(); + } + + const abortController = new AbortController(); + this._moatAbortControllers.add(abortController); + try { + this._bridges = await this._moatRPC.check( + "obfs4", + this._challenge, + solution, + abortController.signal + ); + } finally { + this._moatAbortControllers.delete(abortController); + } + return this._bridges; + }, + + async requestNewCaptchaImage() { + try { + if (!this._moatRPC) { + this._moatRPC = new lazy.MoatRPC(); + await this._moatRPC.init(); + } + + const abortController = new AbortController(); + this._moatAbortControllers.add(abortController); + let response; + try { + response = await this._moatRPC.fetch(["obfs4"], abortController.signal); + } finally { + this._moatAbortControllers.delete(abortController); + } + if (response) { + // Not cancelled. + this._challenge = response.challenge; + this._image = + "data:image/jpeg;base64," + encodeURIComponent(response.image); + } + } catch (err) { + console.error("Could not request a captcha image", err); + } + return this._image; + }, + + close() { + // Abort any ongoing requests. + for (const controller of this._moatAbortControllers) { + controller.abort(); + } + this._moatAbortControllers.clear(); + this._moatRPC?.uninit(); + this._moatRPC = null; + this._challenge = null; + this._image = null; + this._bridges = null; + }, +}; diff --git a/toolkit/modules/DomainFrontedRequests.sys.mjs b/toolkit/modules/DomainFrontedRequests.sys.mjs @@ -0,0 +1,616 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +const log = console.createInstance({ + maxLogLevel: "Warn", + prefix: "DomainFrontendRequests", +}); + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", + Subprocess: "resource://gre/modules/Subprocess.sys.mjs", + TorLauncherUtil: "resource://gre/modules/TorLauncherUtil.sys.mjs", + TorProviderBuilder: "resource://gre/modules/TorProviderBuilder.sys.mjs", + TorSettings: "resource://gre/modules/TorSettings.sys.mjs", +}); + +/** + * The meek pluggable transport takes the reflector URL and front domain as + * proxy credentials, which can be prepared with this function. + * + * @param {string} proxyType The proxy type (socks for socks5 or socks4) + * @param {string} reflector The URL of the service hosted by the CDN + * @param {string} front The domain to use as a front + * @returns {string[]} An array containing [username, password] + */ +function makeMeekCredentials(proxyType, reflector, front) { + // Construct the per-connection arguments. + let meekClientEscapedArgs = ""; + + // 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. + const escapeArgValue = aValue => + aValue + ? aValue + .replaceAll("\\", "\\\\") + .replaceAll("=", "\\=") + .replaceAll(";", "\\;") + : ""; + + if (reflector) { + meekClientEscapedArgs += "url="; + meekClientEscapedArgs += escapeArgValue(reflector); + } + + if (front) { + if (meekClientEscapedArgs.length) { + meekClientEscapedArgs += ";"; + } + meekClientEscapedArgs += "front="; + meekClientEscapedArgs += escapeArgValue(front); + } + + // socks5 + if (proxyType === "socks") { + if (meekClientEscapedArgs.length <= 255) { + return [meekClientEscapedArgs, "\x00"]; + } + return [ + meekClientEscapedArgs.substring(0, 255), + meekClientEscapedArgs.substring(255), + ]; + } else if (proxyType === "socks4") { + return [meekClientEscapedArgs, undefined]; + } + throw new Error(`Unsupported proxy type ${proxyType}.`); +} + +/** + * Subprocess-based implementation to launch and control a PT process. + */ +class MeekTransport { + // These members are used by consumers to setup the proxy to do requests over + // meek. They are passed to newProxyInfoWithAuth. + proxyType = null; + proxyAddress = null; + proxyPort = 0; + proxyUsername = null; + proxyPassword = null; + + #inited = false; + #meekClientProcess = null; + + // launches the meekprocess + async init(reflector, front) { + // ensure we haven't already init'd + if (this.#inited) { + throw new Error("MeekTransport: Already initialized"); + } + + try { + // figure out which pluggable transport to use + const supportedTransports = ["meek", "meek_lite"]; + const provider = await lazy.TorProviderBuilder.build(); + const proxy = (await provider.getPluggableTransports()).find( + pt => + pt.type === "exec" && + supportedTransports.some(t => pt.transports.includes(t)) + ); + if (!proxy) { + throw new Error("No supported transport found."); + } + + const meekTransport = proxy.transports.find(t => + supportedTransports.includes(t) + ); + // Convert meek client path to absolute path if necessary + const meekWorkDir = lazy.TorLauncherUtil.getTorFile( + "pt-startup-dir", + false + ); + if (lazy.TorLauncherUtil.isPathRelative(proxy.pathToBinary)) { + const meekPath = meekWorkDir.clone(); + meekPath.appendRelativePath(proxy.pathToBinary); + proxy.pathToBinary = meekPath.path; + } + + // Setup env and start meek process + const ptStateDir = lazy.TorLauncherUtil.getTorFile("tordatadir", false); + ptStateDir.append("pt_state"); // Match what tor uses. + + const 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, + }; + if (lazy.TorSettings.proxy.enabled) { + envAdditions.TOR_PT_PROXY = lazy.TorSettings.proxyUri; + } + + const opts = { + command: proxy.pathToBinary, + arguments: proxy.options.split(/s+/), + workdir: meekWorkDir.path, + environmentAppend: true, + environment: envAdditions, + stderr: "pipe", + }; + + // Launch meek client + this.#meekClientProcess = await lazy.Subprocess.call(opts); + + // Callback chain for reading stderr + const stderrLogger = async () => { + while (this.#meekClientProcess) { + const errString = await this.#meekClientProcess.stderr.readString(); + if (errString) { + log.error(`MeekTransport: stderr => ${errString}`); + } + } + }; + stderrLogger(); + + // Read pt's stdout until terminal (CMETHODS DONE) is reached + // returns array of lines for parsing + const getInitLines = async (stdout = "") => { + stdout += await this.#meekClientProcess.stdout.readString(); + + // 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.substring(0, endIndex).split("\n"); + } + return getInitLines(stdout); + }; + + // read our lines from pt's stdout + const meekInitLines = await getInitLines(); + // tokenize our pt lines + const meekInitTokens = meekInitLines.map(line => { + const tokens = line.split(" "); + return { + keyword: tokens[0], + args: tokens.slice(1), + }; + }); + + // 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 + this.proxyType = proxyType === "socks5" ? "socks" : "socks4"; + this.proxyAddress = addr; + this.proxyPort = 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}'` + ); + } + } + + // register callback to cleanup on process exit + this.#meekClientProcess.wait().then(() => { + this.#meekClientProcess = null; + this.uninit(); + }); + [this.proxyUsername, this.proxyPassword] = makeMeekCredentials( + this.proxyType, + reflector, + front + ); + this.#inited = true; + } catch (ex) { + if (this.#meekClientProcess) { + this.#meekClientProcess.kill(); + this.#meekClientProcess = null; + } + throw ex; + } + } + + async uninit() { + this.#inited = false; + + await this.#meekClientProcess?.kill(); + this.#meekClientProcess = null; + this.proxyType = null; + this.proxyAddress = null; + this.proxyPort = 0; + this.proxyUsername = null; + this.proxyPassword = null; + } +} + +/** + * Android implementation of the Meek process. + * + * GeckoView does not provide the subprocess module, so we have to use the + * EventDispatcher, and have a Java handler start and stop the proxy process. + */ +class MeekTransportAndroid { + // These members are used by consumers to setup the proxy to do requests over + // meek. They are passed to newProxyInfoWithAuth. + proxyType = null; + proxyAddress = null; + proxyPort = 0; + proxyUsername = null; + proxyPassword = null; + + /** + * An id for process this instance is linked to. + * + * Since we do not restrict the transport to be a singleton, we need a handle to + * identify the process we want to stop when the transport owner is done. + * We use a counter incremented on the Java side for now. + * + * This number must be a positive integer (i.e., 0 is an invalid handler). + * + * @type {number} + */ + #id = 0; + + async init(reflector, front) { + // ensure we haven't already init'd + if (this.#id) { + throw new Error("MeekTransport: Already initialized"); + } + const details = await lazy.EventDispatcher.instance.sendRequestForResult({ + type: "GeckoView:Tor:StartMeek", + }); + this.#id = details.id; + this.proxyType = "socks"; + this.proxyAddress = details.address; + this.proxyPort = details.port; + [this.proxyUsername, this.proxyPassword] = makeMeekCredentials( + this.proxyType, + reflector, + front + ); + } + + async uninit() { + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:Tor:StopMeek", + id: this.#id, + }); + this.#id = 0; + this.proxyType = null; + this.proxyAddress = null; + this.proxyPort = 0; + this.proxyUsername = null; + this.proxyPassword = null; + } +} + +/** + * Corresponds to a Network error with the request. + */ +export class DomainFrontRequestNetworkError extends Error { + constructor(request, statusCode) { + super(`Error fetching ${request.name}: ${statusCode}`); + this.name = "DomainFrontRequestNetworkError"; + this.statusCode = statusCode; + } +} + +/** + * Corresponds to a non-ok response from the server. + */ +export class DomainFrontRequestResponseError extends Error { + constructor(request) { + super( + `Error response from ${request.name} server: ${request.responseStatus}` + ); + this.name = "DomainFrontRequestResponseError"; + this.status = request.responseStatus; + this.statusText = request.responseStatusText; + } +} + +/** + * Thrown when the caller cancels the request. + */ +export class DomainFrontRequestCancelledError extends Error { + constructor(url) { + super(`Cancelled request to ${url}`); + } +} + +/** + * Callback object to promisify the XPCOM request. + */ +class ResponseListener { + #response = ""; + #responsePromise; + #resolve; + #reject; + 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; + }); + } + + /** + * A promise that resolves to the response body from the request. + * + * @type {Promise<string>} + */ + get response() { + return this.#responsePromise; + } + + // noop + onStartRequest() {} + + // resolve or reject our Promise + onStopRequest(request, status) { + try { + if (!Components.isSuccessCode(status)) { + // Assume this is a network error. + this.#reject(new DomainFrontRequestNetworkError(request, status)); + } + if (request.responseStatus !== 200) { + this.#reject(new DomainFrontRequestResponseError(request)); + } + } 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); + } +} + +/** + * Factory to create HTTP(S) requests over a domain fronted transport. + */ +export class DomainFrontRequestBuilder { + #inited = false; + #meekTransport = null; + + get inited() { + return this.#inited; + } + + async init(reflector, front) { + if (this.#inited) { + throw new Error("DomainFrontRequestBuilder: Already initialized"); + } + + const meekTransport = + Services.appinfo.OS === "Android" + ? new MeekTransportAndroid() + : new MeekTransport(); + await meekTransport.init(reflector, front); + this.#meekTransport = meekTransport; + this.#inited = true; + } + + async uninit() { + await this.#meekTransport?.uninit(); + this.#meekTransport = null; + this.#inited = false; + } + + buildHttpHandler(uriString) { + if (!this.#inited) { + throw new Error("DomainFrontRequestBuilder: Not initialized"); + } + + const { proxyType, proxyAddress, proxyPort, proxyUsername, proxyPassword } = + this.#meekTransport; + + 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 => { + if (key !== "Host") { + headers.push(key); + } + }, + }); + headers.forEach(key => ch.setRequestHeader(key, "", false)); + + return ch; + } + + /** + * Make a request. + * + * @param {string} url The URL to request. + * @param {object} args The arguments to send to the procedure. + * @param {string} args.method The request method. + * @param {string} args.body The request body. + * @param {string} args.contentType The "Content-Type" header to set. + * @param {AbortSignal} [args.signal] An optional means of cancelling + * the request early. Will throw DomainFrontRequestCancelledError if + * aborted. + * @returns {Promise<string>} A promise that resolves to the response body. + */ + buildRequest(url, args) { + // Pre-fetch the argument values from `args` so the caller cannot change the + // parameters mid-call. + const { body, method, contentType, signal } = args; + let cancel = null; + const promise = new Promise((resolve, reject) => { + if (signal?.aborted) { + // Unexpected, cancel immediately. + reject(new DomainFrontRequestCancelledError(url)); + return; + } + + let ch = null; + + if (signal) { + cancel = () => { + // Reject prior to calling cancel, since we want to ignore any error + // responses from ResponseListener. + // NOTE: In principle we could let ResponseListener throw this error + // when it receives NS_ERROR_ABORT, but that would rely on mozilla + // never calling this error either. + reject(new DomainFrontRequestCancelledError(url)); + ch?.cancel(Cr.NS_ERROR_ABORT); + }; + signal.addEventListener("abort", cancel); + } + + ch = this.buildHttpHandler(url); + + const inStream = Cc[ + "@mozilla.org/io/string-input-stream;1" + ].createInstance(Ci.nsIStringInputStream); + inStream.setByteStringData(body); + const upChannel = ch.QueryInterface(Ci.nsIUploadChannel); + upChannel.setUploadStream(inStream, contentType, body.length); + ch.requestMethod = method; + + // Make request + const listener = new ResponseListener(); + ch.asyncOpen(listener); + listener.response.then( + body => { + resolve(body); + }, + error => { + reject(error); + } + ); + }); + // Clean up. Do not return this `Promise.finally` since the caller should + // not depend on it. + // We pre-catch and suppress all errors for this `.finally` to stop the + // errors from being duplicated in the console log. + promise + .catch(() => {}) + .finally(() => { + // Remove the callback for the AbortSignal so that it doesn't hold onto + // our channel reference if the caller continues to hold a reference to + // AbortSignal. + if (cancel) { + signal.removeEventListener("abort", cancel); + } + }); + return promise; + } +} diff --git a/toolkit/modules/Moat.sys.mjs b/toolkit/modules/Moat.sys.mjs @@ -0,0 +1,392 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +const log = console.createInstance({ + prefix: "Moat", + maxLogLevelPref: "browser.torMoat.loglevel", +}); + +ChromeUtils.defineESModuleGetters(lazy, { + DomainFrontRequestBuilder: + "resource://gre/modules/DomainFrontedRequests.sys.mjs", + DomainFrontRequestCancelledError: + "resource://gre/modules/DomainFrontedRequests.sys.mjs", + TorBridgeSource: "resource://gre/modules/TorSettings.sys.mjs", +}); + +const TorLauncherPrefs = Object.freeze({ + bridgedb_front: "extensions.torlauncher.bridgedb_front", + bridgedb_reflector: "extensions.torlauncher.bridgedb_reflector", + moat_service: "extensions.torlauncher.moat_service", +}); + +/** + * @typedef {object} MoatBridges + * + * Bridge settings that can be passed to TorSettings.bridges. + * + * @property {number} source - The `TorBridgeSource` type. + * @property {string} [builtin_type] - The built-in bridge type. + * @property {string[]} [bridge_strings] - The bridge lines. + */ + +/** + * @typedef {object} MoatSettings + * + * The settings returned by Moat. + * + * @property {MoatBridges[]} bridgesList - The list of bridges found. + * @property {string} [country] - The detected country (region). + */ + +/** + * @typedef {object} CaptchaChallenge + * + * The details for a captcha challenge. + * + * @property {string} transport - The transport type selected by the Moat + * server. + * @property {string} image - A base64 encoded jpeg with the captcha to + * complete. + * @property {string} challenge - A nonce/cookie string associated with this + * request. + */ + +/** + * Constructs JSON objects and sends requests over Moat. + * The documentation about the JSON schemas to use are available at + * https://gitlab.torproject.org/tpo/anti-censorship/rdsys/-/blob/main/doc/moat.md. + */ +export class MoatRPC { + #requestBuilder = null; + + async init() { + if (this.#requestBuilder !== null) { + return; + } + + const reflector = Services.prefs.getStringPref( + TorLauncherPrefs.bridgedb_reflector + ); + const front = Services.prefs.getStringPref(TorLauncherPrefs.bridgedb_front); + this.#requestBuilder = new lazy.DomainFrontRequestBuilder(); + try { + await this.#requestBuilder.init(reflector, front); + } catch (e) { + this.#requestBuilder = null; + throw e; + } + } + + async uninit() { + await this.#requestBuilder?.uninit(); + this.#requestBuilder = null; + } + + /** + * @typedef {object} MoatResult + * + * The result of a Moat request. + * + * @property {any} response - The parsed JSON response from the Moat server, + * or `undefined` if the request was cancelled. + * @property {boolean} cancelled - Whether the request was cancelled. + */ + + /** + * Make a request to Moat. + * + * @param {string} procedure - The name of the procedure. + * @param {object} args - The arguments to pass in as a JSON string. + * @param {AbortSignal} [abortSignal] - An optional signal to be able to abort + * the request early. + * @returns {MoatResult} - The result of the request. + */ + async #makeRequest(procedure, args, abortSignal) { + const procedureURIString = `${Services.prefs.getStringPref( + TorLauncherPrefs.moat_service + )}/${procedure}`; + log.info(`Making request to ${procedureURIString}:`, args); + let response = undefined; + let cancelled = false; + try { + response = JSON.parse( + await this.#requestBuilder.buildRequest(procedureURIString, { + method: "POST", + contentType: "application/vnd.api+json", + body: JSON.stringify(args), + signal: abortSignal, + }) + ); + log.info(`Response to ${procedureURIString}:`, response); + } catch (e) { + if (abortSignal && e instanceof lazy.DomainFrontRequestCancelledError) { + log.info(`Request to ${procedureURIString} cancelled`); + cancelled = true; + } else { + throw e; + } + } + return { response, cancelled }; + } + + /** + * Request a CAPTCHA challenge. + * + * @param {string[]} transports - List of transport strings available to us + * eg: ["obfs4", "meek"]. + * @param {AbortSignal} abortSignal - A signal to abort the request early. + * @returns {?CaptchaChallenge} - The captcha challenge, or `null` if the + * request was aborted by the caller. + */ + async fetch(transports, abortSignal) { + 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, cancelled } = await this.#makeRequest( + "fetch", + args, + abortSignal + ); + if (cancelled) { + return null; + } + + 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 previous CAPTCHA fetch to get bridges. + * + * @param {string} transport - The transport associated with the fetch. + * @param {string} challenge - The nonce string associated with the fetch. + * @param {string} solution - The solution to the CAPTCHA. + * @param {AbortSignal} abortSignal - A signal to abort the request early. + * @returns {?string[]} - The bridge lines for a correct solution, or `null` + * if the solution was incorrect or the request was aborted by the caller. + */ + async check(transport, challenge, solution, abortSignal) { + const args = { + data: [ + { + id: "2", + version: "0.1.0", + type: "moat-solution", + transport, + challenge, + solution, + qrcode: "false", + }, + ], + }; + const { response, cancelled } = await this.#makeRequest( + "check", + args, + abortSignal + ); + if (cancelled) { + return null; + } + + 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 null; + } + + throw new Error(`MoatRPC: ${detail} (${code})`); + } + + return response.data[0].bridges; + } + + /** + * Extract bridges from the received Moat settings object. + * + * @param {object} settings - The received settings. + * @returns {MoatBridge} The extracted bridges. + */ + #extractBridges(settings) { + if (!("bridges" in settings)) { + throw new Error("Expected to find `bridges` in the settings object."); + } + const bridges = {}; + switch (settings.bridges.source) { + case "builtin": + bridges.source = lazy.TorBridgeSource.BuiltIn; + bridges.builtin_type = String(settings.bridges.type); + // Ignore the bridge_strings argument since we will use our built-in + // bridge strings instead. + break; + case "bridgedb": + bridges.source = lazy.TorBridgeSource.BridgeDB; + if (settings.bridges.bridge_strings?.length) { + bridges.bridge_strings = Array.from( + settings.bridges.bridge_strings, + item => String(item) + ); + } else { + throw new Error( + "Received no bridge-strings for BridgeDB bridge source" + ); + } + break; + default: + throw new Error( + `Unexpected bridge source '${settings.bridges.source}'` + ); + } + return bridges; + } + + /** + * Extract a list of bridges from the received Moat settings object. + * + * @param {object} settingsList - The received settings. + * @returns {MoatBridge[]} The list of extracted bridges. + */ + #extractBridgesList(settingsList) { + const bridgesList = []; + for (const settings of settingsList) { + try { + bridgesList.push(this.#extractBridges(settings)); + } catch (ex) { + log.error(ex); + } + } + return bridgesList; + } + + /** + * Request tor settings for the user optionally based on their location + * (derived from their IP). Takes the following parameters: + * + * @param {string[]} transports - A list of transports we support. + * @param {?string} country - The region to request bridges for, as an + * ISO 3166-1 alpha-2 region code, or `null` to have the server + * automatically determine the region. + * @param {AbortSignal} abortSignal - A signal to abort the request early. + * @returns {?MoatSettings} - The returned settings from the server, or `null` + * if the region could not be determined by the server or the caller + * cancelled the request. + */ + async circumvention_settings(transports, country, abortSignal) { + const args = { + transports: transports ? transports : [], + country, + }; + const { response, cancelled } = await this.#makeRequest( + "circumvention/settings", + args, + abortSignal + ); + if (cancelled) { + return null; + } + let settings = {}; + if ("errors" in response) { + const code = response.errors[0].code; + const detail = response.errors[0].detail; + if (code == 406) { + log.error( + "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.bridgesList = this.#extractBridgesList(response.settings); + } + if ("country" in response) { + settings.country = response.country; + } + return settings; + } + + // 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 default/fallback bridge settings. + * + * @param {string[]} transports - A list of transports we support. + * @param {AbortSignal} abortSignal - A signal to abort the request early. + * @returns {?MoatBridges[]} - The list of bridges found, or `null` if the + * caller cancelled the request. + */ + async circumvention_defaults(transports, abortSignal) { + const args = { + transports: transports ? transports : [], + }; + const { response, cancelled } = await this.#makeRequest( + "circumvention/defaults", + args, + abortSignal + ); + if (cancelled) { + return null; + } + 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.#extractBridgesList(response.settings); + } + return []; + } +} diff --git a/toolkit/modules/TorConnect.sys.mjs b/toolkit/modules/TorConnect.sys.mjs @@ -0,0 +1,1633 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + MoatRPC: "resource://gre/modules/Moat.sys.mjs", + TorBootstrapRequest: "resource://gre/modules/TorBootstrapRequest.sys.mjs", + TorProviderTopics: "resource://gre/modules/TorProviderBuilder.sys.mjs", + TorBootstrapError: "resource://gre/modules/TorProviderBuilder.sys.mjs", + TorProviderInitError: "resource://gre/modules/TorProviderBuilder.sys.mjs", + TorLauncherUtil: "resource://gre/modules/TorLauncherUtil.sys.mjs", + TorSettings: "resource://gre/modules/TorSettings.sys.mjs", + TorSettingsTopics: "resource://gre/modules/TorSettings.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "NetworkLinkService", () => { + // NetworkLinkService is unavailable on some platforms like openBSD. + // See tor-browser#43628. + return Cc["@mozilla.org/network/network-link-service;1"]?.getService( + Ci.nsINetworkLinkService + ); +}); + +const NETWORK_LINK_TOPIC = "network:link-status-changed"; + +const TorConnectPrefs = Object.freeze({ + censorship_level: "torbrowser.debug.censorship_level", + log_level: "torbrowser.bootstrap.log_level", + /* prompt_at_startup now controls whether the quickstart can trigger. */ + prompt_at_startup: "extensions.torlauncher.prompt_at_startup", + quickstart: "torbrowser.settings.quickstart.enabled", +}); + +export 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", +}); + +/** + * A class for exceptions thrown during the bootstrap process. + */ +export class TorConnectError extends Error { + static get Offline() { + return "Offline"; + } + static get BootstrapError() { + return "BootstrapError"; + } + static get CannotDetermineCountry() { + return "CannotDetermineCountry"; + } + static get NoSettingsForCountry() { + return "NoSettingsForCountry"; + } + static get AllSettingsFailed() { + return "AllSettingsFailed"; + } + static get ExternalError() { + return "ExternalError"; + } + + constructor(code, cause) { + super(cause?.message ?? `TorConnectError: ${code}`, cause ? { cause } : {}); + this.name = "TorConnectError"; + this.code = code; + } +} + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + console.createInstance({ + maxLogLevelPref: TorConnectPrefs.log_level, + prefix: "TorConnect", + }) +); + +/* Topics Notified by the TorConnect module */ +export const TorConnectTopics = Object.freeze({ + StageChange: "torconnect:stage-change", + // TODO: Remove torconnect:state-change when pages have switched to stage. + StateChange: "torconnect:state-change", + QuickstartChange: "torconnect:quickstart-change", + InternetStatusChange: "torconnect:internet-status-change", + RegionNamesChange: "torconnect:region-names-change", + BootstrapProgress: "torconnect:bootstrap-progress", + BootstrapComplete: "torconnect:bootstrap-complete", + // TODO: Remove torconnect:error when pages have switched to stage. + Error: "torconnect:error", +}); + +/** + * @callback ProgressCallback + * + * @param {integer} progress - The progress percent. + */ +/** + * @typedef {object} BootstrapOptions + * + * Options for a bootstrap attempt. + * + * @property {boolean} [simulateCensorship] - Whether to simulate a failing + * bootstrap. + * @property {integer} [simulateDelay] - The delay in microseconds to apply to + * simulated bootstraps. + * @property {MoatSettings} [simulateMoatResponse] - Simulate a Moat response + * for circumvention settings. Should include a "bridgesList" property, and + * optionally a "country" property. The "bridgesList" property should be an + * Array of MoatBridges objects that match the bridge settings accepted by + * TorSettings.bridges, plus you may add a "simulateCensorship" property to + * make only their bootstrap attempts fail. + * @property {string} [regionCode] - The region code to use to fetch + * auto-bootstrap settings, or "automatic" to automatically choose the region. + */ +/** + * @typedef {object} BootstrapResult + * + * The result of a bootstrap attempt. + * + * @property {string} [result] - The bootstrap result. + * @property {Error} [error] - An error from the attempt. + */ +/** + * @callback ResolveBootstrap + * + * Resolve a bootstrap attempt. + * + * @param {BootstrapResult} [result] - The result, or error. + */ + +/** + * Each instance can be used to attempt one bootstrapping. + */ +class BootstrapAttempt { + /** + * The ongoing bootstrap request. + * + * @type {?TorBootstrapRequest} + */ + #bootstrap = null; + /** + * The method to call to complete the `run` promise. + * + * @type {?ResolveBootstrap} + */ + #resolveRun = null; + /** + * Whether the `run` promise has been, or is about to be, resolved. + * + * @type {boolean} + */ + #resolved = false; + /** + * Whether a cancel request has been started. + * + * @type {boolean} + */ + #cancelled = false; + + /** + * Run a bootstrap attempt. + * + * @param {ProgressCallback} progressCallback - The callback to invoke with + * the bootstrap progress. + * @param {BootstrapOptions} options - Options to apply to the bootstrap. + * + * @returns {Promise<string, Error>} - The result of the bootstrap. + */ + run(progressCallback, options) { + const { promise, resolve, reject } = Promise.withResolvers(); + this.#resolveRun = arg => { + if (this.#resolved) { + // Already been called once. + if (arg.error) { + lazy.logger.error("Delayed bootstrap error", arg.error); + } + return; + } + this.#resolved = true; + if (arg.error) { + reject(arg.error); + } else { + resolve(arg.result); + } + }; + try { + this.#runInternal(progressCallback, options); + } catch (error) { + this.#resolveRun({ error }); + } + + return promise; + } + + /** + * Method to call just after the Bootstrapped stage is set in response to this + * bootstrap attempt. + */ + postBootstrapped() { + // Nothing to do. + } + + /** + * Run the attempt. + * + * @param {ProgressCallback} progressCallback - The callback to invoke with + * the bootstrap progress. + * @param {BootstrapOptions} options - Options to apply to the bootstrap. + */ + #runInternal(progressCallback, options) { + if (options.simulateCensorship) { + // Create a fake request. + this.#bootstrap = { + _timeout: 0, + bootstrap() { + this._timeout = setTimeout(() => { + const err = new lazy.TorBootstrapError({ + summary: "Censorship simulation", + phase: "conn", + reason: "noroute", + }); + this.onbootstraperror(err); + }, options.simulateDelay || 0); + }, + cancel() { + clearTimeout(this._timeout); + }, + }; + } else { + this.#bootstrap = new lazy.TorBootstrapRequest(); + } + + this.#bootstrap.onbootstrapstatus = (progress, _status) => { + if (!this.#resolved) { + progressCallback(progress); + } + }; + this.#bootstrap.onbootstrapcomplete = () => { + this.#resolveRun({ result: "complete" }); + }; + this.#bootstrap.onbootstraperror = error => { + if (this.#resolved || this.#cancelled) { + // We ignore this error since it occurred after cancelling (by the + // user), or we have already resolved. We assume the error is just a + // side effect of the cancelling. + // E.g. If the cancelling is triggered late in the process, we get + // "Building circuits: Establishing a Tor circuit failed". + // TODO: Maybe move this logic deeper in the process to know when to + // filter out such errors triggered by cancelling. + lazy.logger.warn("Post-complete bootstrap error.", error); + return; + } + + this.#resolveRun({ error }); + }; + + this.#bootstrap.bootstrap(); + } + + /** + * Cancel the bootstrap attempt. + */ + async cancel() { + if (this.#cancelled) { + lazy.logger.warn( + "Cancelled bootstrap after it has already been cancelled" + ); + return; + } + this.#cancelled = true; + if (this.#resolved) { + lazy.logger.warn("Cancelled bootstrap after it has already resolved"); + return; + } + // Wait until after #bootstrap.cancel returns before we resolve with + // cancelled. In particular: + // + there is a small chance that the bootstrap completes, in which case we + // want to be able to resolve with a success instead. + // + we want to make sure that we only resolve this BootstrapAttempt + // when the current TorBootstrapRequest instance is fully resolved so + // there are never two competing instances. + await this.#bootstrap?.cancel(); + this.#resolveRun({ result: "cancelled" }); + } +} + +/** + * Each instance can be used to attempt one auto-bootstrapping sequence. + */ +class AutoBootstrapAttempt { + /** + * The current bootstrap attempt, if any. + * + * @type {?BootstrapAttempt} + */ + #bootstrapAttempt = null; + /** + * The method to call to complete the `run` promise. + * + * @type {?ResolveBootstrap} + */ + #resolveRun = null; + /** + * Whether the `run` promise has been, or is about to be, resolved. + * + * @type {boolean} + */ + #resolved = false; + /** + * Whether a cancel request has been started. + * + * @type {boolean} + */ + #cancelled = false; + /** + * The method to call when the cancelled value is set to true. + * + * @type {?Function} + */ + #resolveCancelled = null; + /** + * A promise that resolves when the cancelled value is set to true. We can use + * this with Promise.race to end early when the user cancels. + * + * @type {?Promise} + */ + #cancelledPromise = null; + /** + * The list of bridge configurations from Moat. + * + * @type {?MoatBridges[]} + */ + #bridgesList = null; + /** + * The detected region code returned by Moat, if any. + * + * @type {?string} + */ + detectedRegion = null; + + /** + * Run an auto-bootstrap attempt. + * + * @param {ProgressCallback} progressCallback - The callback to invoke with + * the bootstrap progress. + * @param {BootstrapOptions} options - Options to apply to the bootstrap. + * + * @returns {Promise<string, Error>} - The result of the bootstrap. + */ + run(progressCallback, options) { + const { promise, resolve, reject } = Promise.withResolvers(); + + this.#resolveRun = async arg => { + if (this.#resolved) { + // Already been called once. + if (arg.error) { + lazy.logger.error("Delayed auto-bootstrap error", arg.error); + } + return; + } + this.#resolved = true; + + if (lazy.TorSettings.initialized) { + // If not initialized, then we won't have applied any settings. + try { + // Run cleanup before we resolve the promise to ensure two instances + // of AutoBootstrapAttempt are not trying to change the settings at + // the same time. + if (arg.result !== "complete") { + // Should do nothing if we never called applyTemporaryBridges. + await lazy.TorSettings.clearTemporaryBridges(); + } + } catch (error) { + lazy.logger.error( + "Unexpected error in auto-bootstrap cleanup", + error + ); + } + } + if (arg.error) { + reject(arg.error); + } else { + resolve(arg.result); + } + }; + + ({ promise: this.#cancelledPromise, resolve: this.#resolveCancelled } = + Promise.withResolvers()); + + this.#runInternal(progressCallback, options).catch(error => { + this.#resolveRun({ error }); + }); + + return promise; + } + + /** + * Method to call just after the Bootstrapped stage is set in response to this + * bootstrap attempt. + */ + postBootstrapped() { + // Persist the current settings to preferences. + lazy.TorSettings.saveTemporaryBridges(); + } + + /** + * Run the attempt. + * + * Note, this is an async method, but should *not* be awaited by the `run` + * method. + * + * @param {ProgressCallback} progressCallback - The callback to invoke with + * the bootstrap progress. + * @param {BootstrapOptions} options - Options to apply to the bootstrap. + */ + async #runInternal(progressCallback, options) { + // Wait for TorSettings to be initialised, which is used for the + // AutoBootstrapping set up. + await Promise.race([ + lazy.TorSettings.initializedPromise, + this.#cancelledPromise, + ]); + if (this.#cancelled || this.#resolved) { + return; + } + + await this.#fetchBridges(options); + if (this.#cancelled || this.#resolved) { + return; + } + + if (!this.#bridgesList?.length) { + this.#resolveRun({ + error: new TorConnectError( + options.regionCode === "automatic" && !this.detectedRegion + ? TorConnectError.CannotDetermineCountry + : TorConnectError.NoSettingsForCountry + ), + }); + } + + // Apply each of our settings and try to bootstrap with each. + for (const [index, bridges] of this.#bridgesList.entries()) { + lazy.logger.info( + `Attempting Bootstrap with configuration ${index + 1}/${ + this.#bridgesList.length + }` + ); + + await this.#tryBridges(bridges, progressCallback, options); + + if (this.#cancelled || this.#resolved) { + return; + } + } + + this.#resolveRun({ + error: new TorConnectError(TorConnectError.AllSettingsFailed), + }); + } + + /** + * Lookup user's potential censorship circumvention settings from Moat + * service. + * + * @param {BootstrapOptions} options - Options to apply to the bootstrap. + */ + async #fetchBridges(options) { + if (options.simulateMoatResponse) { + await Promise.race([ + new Promise(res => setTimeout(res, options.simulateDelay || 0)), + this.#cancelledPromise, + ]); + + if (this.#cancelled || this.#resolved) { + return; + } + + this.detectedRegion = options.simulateMoatResponse.country || null; + this.#bridgesList = options.simulateMoatResponse.bridgesList ?? null; + + return; + } + + const moat = new lazy.MoatRPC(); + try { + // We need to wait Moat's initialization even when we are requested to + // transition to another state to be sure its uninit will have its + // intended effect. So, do not use Promise.race here. + await moat.init(); + + if (this.#cancelled || this.#resolved) { + return; + } + + let moatAbortController = new AbortController(); + // For now, throw any errors we receive from the backend, except when it + // was unable to detect user's country/region. + // If we use specialized error objects, we could pass the original errors + // to them. + const maybeSettings = await Promise.race([ + moat.circumvention_settings( + [...lazy.TorSettings.builtinBridgeTypes, "vanilla"], + options.regionCode === "automatic" ? null : options.regionCode, + moatAbortController.signal + ), + // This might set maybeSettings to undefined. + this.#cancelledPromise, + ]); + if (this.#cancelled) { + // Ended early due to being cancelled. Abort the ongoing Moat request so + // that it does not continue unnecessarily in the background. + // NOTE: We do not care about circumvention_settings return value or + // errors at this point. Nor do we need to await its return. We just + // want it to resolve quickly. + moatAbortController.abort(); + } + if (this.#cancelled || this.#resolved) { + return; + } + + this.detectedRegion = maybeSettings?.country || null; + + if (maybeSettings?.bridgesList?.length) { + this.#bridgesList = maybeSettings.bridgesList; + } else { + // In principle we could reuse the existing moatAbortController + // instance, since its abort method has not been called. But it is + // cleaner to use a new instance to avoid triggering any potential + // lingering callbacks attached to the AbortSignal. + moatAbortController = new AbortController(); + // Keep consistency with the other call. + this.#bridgesList = await Promise.race([ + moat.circumvention_defaults( + [...lazy.TorSettings.builtinBridgeTypes, "vanilla"], + moatAbortController.signal + ), + // This might set this.#bridgesList to undefined. + this.#cancelledPromise, + ]); + if (this.#cancelled) { + // Ended early due to being cancelled. Abort the ongoing Moat request + // so that it does not continue in the background. + moatAbortController.abort(); + } + } + } finally { + // Do not await the uninit. + moat.uninit(); + } + } + + /** + * Try to apply the settings we fetched. + * + * @param {MoatBridges} bridges - The bridges to try. + * @param {ProgressCallback} progressCallback - The callback to invoke with + * the bootstrap progress. + * @param {BootstrapOptions} options - Options to apply to the bootstrap. + */ + async #tryBridges(bridges, progressCallback, options) { + if (this.#cancelled || this.#resolved) { + return; + } + + if (options.simulateMoatResponse && bridges.simulateCensorship) { + // Move the simulateCensorship option to the options for the next + // BootstrapAttempt. + bridges = structuredClone(bridges); + delete bridges.simulateCensorship; + options = { ...options, simulateCensorship: true }; + } + + // Send the new settings directly to the provider. We will save them only + // if the bootstrap succeeds. + await lazy.TorSettings.applyTemporaryBridges(bridges); + + if (this.#cancelled || this.#resolved) { + return; + } + + let result; + try { + this.#bootstrapAttempt = new BootstrapAttempt(); + // At this stage, cancelling AutoBootstrap will also cancel this + // bootstrapAttempt. + result = await this.#bootstrapAttempt.run(progressCallback, options); + } catch (error) { + // Only re-try with the next settings *if* we have a TorBootstrapError. + // Other errors will end this auto-bootstrap attempt entirely. + if (error instanceof lazy.TorBootstrapError) { + lazy.logger.info("TorConnect setting failed", bridges, error); + // Try with the next settings. + // NOTE: We do not restore the user settings in between these runs. + // Instead we wait for #resolveRun callback to do so. + // This means there is a window of time where the setting is applied, but + // no bootstrap is running. + return; + } + // Pass error up. + throw error; + } finally { + this.#bootstrapAttempt = null; + } + + if (this.#cancelled || this.#resolved) { + return; + } + + // Pass the BootstrapAttempt result up. + this.#resolveRun({ result }); + } + + /** + * Cancel the bootstrap attempt. + */ + async cancel() { + if (this.#cancelled) { + lazy.logger.warn( + "Cancelled auto-bootstrap after it has already been cancelled" + ); + return; + } + this.#cancelled = true; + this.#resolveCancelled(); + if (this.#resolved) { + lazy.logger.warn( + "Cancelled auto-bootstrap after it has already resolved" + ); + return; + } + + // Wait until after #bootstrapAttempt.cancel returns before we resolve with + // cancelled. In particular: + // + there is a small chance that the bootstrap completes, in which case we + // want to be able to resolve with a success instead. + // + we want to make sure that we only resolve this AutoBootstrapAttempt + // when the current TorBootstrapRequest instance is fully resolved so + // there are never two competing instances. + if (this.#bootstrapAttempt) { + await this.#bootstrapAttempt.cancel(); + } + // In case no bootstrap is running, we resolve with "cancelled". + this.#resolveRun({ result: "cancelled" }); + } +} + +export const InternetStatus = Object.freeze({ + Unknown: -1, + Offline: 0, + Online: 1, +}); + +// This enum is mirrored for Android in TorConnectStageName.java. Changes should be mirrored there +export const TorConnectStage = Object.freeze({ + Disabled: "Disabled", + Loading: "Loading", + Start: "Start", + Bootstrapping: "Bootstrapping", + Offline: "Offline", + ChooseRegion: "ChooseRegion", + RegionNotFound: "RegionNotFound", + ConfirmRegion: "ConfirmRegion", + FinalError: "FinalError", + Bootstrapped: "Bootstrapped", +}); + +/** + * @typedef {object} ConnectStage + * + * A summary of the user stage. + * (This class is mirrored for Android in TorConnectStage.java. Changes should be mirrored there) + * + * @property {string} name - The name of the stage. One of the values in + * `TorConnectStage`. + * @property {string} defaultRegion - The default region to show in the UI. + * @property {?string} bootstrapTrigger - The name of the stage prior to this + * bootstrap attempt. Only set during the "Bootstrapping" stage. + * @property {boolean} isQuickstart - Whether the current bootstrap attempt was + * triggered by the `TorConnect.quickstart` preference. Will be `false` + * outside of the "Bootstrapping" stage. + * @property {?BootstrapError} error - The last bootstrapping error. + * @property {boolean} tryAgain - Whether a bootstrap attempt has failed, so + * that a normal bootstrap should be shown as "Try Again" instead of + * "Connect". NOTE: to be removed when about:torconnect no longer uses + * breadcrumbs. + * @property {boolean} potentiallyBlocked - Whether bootstrapping has ever + * failed, not including being cancelled or being offline. I.e. whether we + * have reached an error stage at some point before being bootstrapped. + * @property {BootstrappingStatus} bootstrappingStatus - The current + * bootstrapping status. + */ + +/** + * @typedef {object} BootstrappingStatus + * + * The status of a bootstrap. + * (This class is mirrored for Android in TorBootstrapping.java. Changes should be mirrored there) + * + * @property {number} progress - The percent progress. + * @property {boolean} hasWarning - Whether this bootstrap has a warning in the + * Tor log. + */ + +/** + * @typedef {object} BootstrapError + * + * Details about the error that caused bootstrapping to fail. + * (This class is mirrored for Android in TorError.java. Changes should be mirrored there) + * + * @property {string} code - The error code type. + * @property {string} message - The error message. + * @property {?string} phase - The bootstrapping phase that failed. + * @property {?string} reason - The bootstrapping failure reason. + */ + +export const TorConnect = { + /** + * Default bootstrap options for simulation. + * + * @type {BootstrapOptions} + */ + simulateBootstrapOptions: {}, + + /** + * Whether to simulate being offline. + * + * @type {boolean} + */ + simulateOffline: false, + + /** + * The name of the current stage the user is in. + * + * @type {string} + */ + _stageName: TorConnectStage.Loading, + + get stageName() { + return this._stageName; + }, + + /** + * The stage that triggered bootstrapping. + * + * @type {?string} + */ + _bootstrapTrigger: null, + + /** + * Whether the current bootstrapping attempt was triggered by the quickstart + * preference. + * + * @type {boolean} + */ + _isQuickstart: false, + + /** + * The alternative stage that we should move to after bootstrapping completes. + * + * @type {?string} + */ + _requestedStage: null, + + /** + * The default region to show in the UI for auto-bootstrapping. + * + * @type {string} + */ + _defaultRegion: "automatic", + + /** + * The current bootstrap attempt, if any. + * + * @type {?(BootstrapAttempt|AutoBootstrapAttempt)} + */ + _bootstrapAttempt: null, + + /** + * The bootstrap error that was last generated. + * + * @type {?TorConnectError} + */ + _errorDetails: null, + + /** + * Whether a bootstrap attempt has failed, so that a normal bootstrap should + * be shown as "Try Again" instead of "Connect". + * + * @type {boolean} + */ + // TODO: Drop tryAgain when we remove breadcrumbs and use "Start again" + // instead. + _tryAgain: false, + + /** + * Whether bootstrapping has ever returned an error. + * + * @type {boolean} + */ + _potentiallyBlocked: false, + + /** + * Get a summary of the current user stage. + * + * @type {ConnectStage} + */ + get stage() { + return { + name: this._stageName, + defaultRegion: this._defaultRegion, + bootstrapTrigger: this._bootstrapTrigger, + isQuickstart: this._isQuickstart, + error: this._errorDetails + ? { + code: this._errorDetails.code, + message: String(this._errorDetails.message ?? ""), + phase: this._errorDetails.cause?.phase ?? null, + reason: this._errorDetails.cause?.reason ?? null, + } + : null, + tryAgain: this._tryAgain, + potentiallyBlocked: this._potentiallyBlocked, + bootstrappingStatus: structuredClone(this._bootstrappingStatus), + }; + }, + + /** + * Promise that resolves to a list of region codes that Moat has special + * bridge settings for. + * + * @type {Promise<string[]>} + */ + _moatRegionsPromise: null, + + /** + * The map of all regions and their localized names. Or `null` when + * uninitialized. + * + * @type {?object} + */ + _regionNames: null, + + /** + * The status of the most recent bootstrap attempt. + * + * @type {BootstrappingStatus} + */ + _bootstrappingStatus: { + progress: 0, + hasWarning: false, + }, + + /** + * Notify the bootstrap progress. + */ + _notifyBootstrapProgress() { + lazy.logger.debug("BootstrappingStatus", this._bootstrappingStatus); + Services.obs.notifyObservers( + this._bootstrappingStatus, + TorConnectTopics.BootstrapProgress + ); + }, + + /** + * The current internet status. One of the InternetStatus values. + * + * @type {integer} + */ + _internetStatus: InternetStatus.Unknown, + + get internetStatus() { + return this._internetStatus; + }, + + /** + * Update the _internetStatus value. + */ + _updateInternetStatus() { + let newStatus; + if (lazy.NetworkLinkService?.linkStatusKnown) { + newStatus = lazy.NetworkLinkService.isLinkUp + ? InternetStatus.Online + : InternetStatus.Offline; + } else { + newStatus = InternetStatus.Unknown; + } + if (newStatus === this._internetStatus) { + return; + } + this._internetStatus = newStatus; + Services.obs.notifyObservers(null, TorConnectTopics.InternetStatusChange); + }, + + // init should be called by TorStartupService + init() { + lazy.logger.debug("TorConnect.init()"); + + if (!this.enabled) { + // Disabled + this._setStage(TorConnectStage.Disabled); + return; + } + + this._moatRegionsPromise = fetch( + "chrome://global/content/moat_countries.json" + ) + .then(req => req.json()) + // Filter out the "_comment" object in the moat_countries_dev_build.json + // file. + .then(regionList => regionList.filter(r => typeof r === "string")) + .catch(e => { + lazy.logger.error("Failed to fetch Moat region codes", e); + return []; + }); + + let observeTopic = addTopic => { + Services.obs.addObserver(this, addTopic); + lazy.logger.debug(`Observing topic '${addTopic}'`); + }; + + // register the Tor topics we always care about + observeTopic(lazy.TorProviderTopics.ProcessExited); + observeTopic(lazy.TorProviderTopics.HasWarnOrErr); + observeTopic(lazy.TorSettingsTopics.SettingsChanged); + observeTopic(NETWORK_LINK_TOPIC); + observeTopic("intl:app-locales-changed"); + + this._updateInternetStatus(); + + // NOTE: At this point, _requestedStage should still be `null`. + this._setStage(TorConnectStage.Start); + if ( + // Quickstart setting is enabled. + this.quickstart && + // And the previous bootstrap attempt must have succeeded. + !Services.prefs.getBoolPref(TorConnectPrefs.prompt_at_startup, true) + ) { + this._beginBootstrappingInternal(undefined, true); + } + }, + + async observe(subject, topic) { + lazy.logger.debug(`Observed ${topic}`); + + switch (topic) { + case lazy.TorProviderTopics.HasWarnOrErr: + if (this._bootstrappingStatus.hasWarning) { + // No change. + return; + } + if (this._stageName === "Bootstrapping") { + this._bootstrappingStatus.hasWarning = true; + this._notifyBootstrapProgress(); + } + break; + case lazy.TorProviderTopics.ProcessExited: + lazy.logger.info("Starting again since the tor process exited"); + // Treat a failure as a possibly broken configuration. + // So, prevent quickstart at the next start. + Services.prefs.setBoolPref(TorConnectPrefs.prompt_at_startup, true); + this._makeStageRequest(TorConnectStage.Start, true); + break; + case lazy.TorSettingsTopics.SettingsChanged: + if ( + this._stageName !== TorConnectStage.Bootstrapped && + this._stageName !== TorConnectStage.Loading && + this._stageName !== TorConnectStage.Start && + subject.wrappedJSObject.changes.some(propertyName => + propertyName.startsWith("bridges.") + ) + ) { + // A change in bridge settings before we are bootstrapped, we reset + // the stage to Start to: + // + Cancel any ongoing bootstrap attempt. In particular, we + // definitely do not want to continue with an auto-bootstrap's + // temporary bridges if the settings have changed. + // + Reset the UI to be ready for normal bootstrapping in case the + // user returns to about:torconnect. + // See tor-browser#41921. + // NOTE: We do not reset in response to a change in the quickstart, + // firewall or proxy settings. + lazy.logger.warn( + "Resetting to Start stage since bridge settings changed" + ); + // Rather than cancel and return to the pre-bootstrap stage, we + // explicitly cancel and return to the start stage. + this._makeStageRequest(TorConnectStage.Start); + } + break; + case NETWORK_LINK_TOPIC: + this._updateInternetStatus(); + break; + case "intl:app-locales-changed": + // Unset the regionNames to use the new app locale when requested. + this._regionNames = null; + // Let consumers know that the list of names has changed. + Services.obs.notifyObservers(null, TorConnectTopics.RegionNamesChange); + break; + } + }, + + /** + * Set the user stage. + * + * @param {string} name - The name of the stage to move to. + */ + _setStage(name) { + if (this._bootstrapAttempt) { + throw new Error(`Trying to set the stage to ${name} during a bootstrap`); + } + + lazy.logger.info(`Entering stage ${name}`); + const prevState = this.state; + this._stageName = name; + this._bootstrappingStatus.hasWarning = false; + this._bootstrappingStatus.progress = + name === TorConnectStage.Bootstrapped ? 100 : 0; + + Services.obs.notifyObservers(this.stage, TorConnectTopics.StageChange); + + // TODO: Remove when all pages have switched to stage. + const newState = this.state; + if (prevState !== newState) { + Services.obs.notifyObservers( + { state: newState }, + TorConnectTopics.StateChange + ); + } + + // Update the progress after the stage has changed. + this._notifyBootstrapProgress(); + }, + + /* + Various getters + */ + + /** + * Whether TorConnect is enabled. + * + * @type {boolean} + */ + get enabled() { + // FIXME: This is called before the TorProvider is ready. + // As a matter of fact, at the moment it is equivalent to the following + // line, but this might become a problem in the future. + return lazy.TorLauncherUtil.shouldStartAndOwnTor; + }, + + /** + * Whether bootstrapping can begin immediately once Tor Browser has been + * opened. + * + * @type {boolean} + */ + get quickstart() { + return Services.prefs.getBoolPref(TorConnectPrefs.quickstart, false); + }, + + set quickstart(enabled) { + enabled = Boolean(enabled); + if (enabled === this.quickstart) { + return; + } + Services.prefs.setBoolPref(TorConnectPrefs.quickstart, enabled); + Services.obs.notifyObservers(null, TorConnectTopics.QuickstartChange); + }, + + get shouldShowTorConnect() { + // TorBrowser must control the daemon + return ( + this.enabled && + // if we have succesfully bootstraped, then no need to show TorConnect + this._stageName !== TorConnectStage.Bootstrapped + ); + }, + + /** + * Whether we are in a stage that can lead into a "normal" bootstrapping + * request. + * + * The value may change with TorConnectTopics.StageChanged. + */ + get canBeginNormalBootstrap() { + return ( + this._stageName === TorConnectStage.Start || + this._stageName === TorConnectStage.Offline + ); + }, + + /** + * Whether we are in an error stage that can lead into the Bootstrapping + * stage. I.e. whether we can make an "auto" bootstrapping request. + * + * The value may change with TorConnectTopics.StageChanged. + */ + get canBeginAutoBootstrap() { + return ( + this._stageName === TorConnectStage.ChooseRegion || + this._stageName === TorConnectStage.RegionNotFound || + this._stageName === TorConnectStage.ConfirmRegion + ); + }, + + // TODO: Remove when all pages have switched to "stage". + get state() { + // There is no "Error" stage, but about:torconnect relies on receiving the + // Error state to update its display. So we temporarily set the stage for a + // StateChange signal. + if (this._isErrorState) { + return TorConnectState.Error; + } + switch (this._stageName) { + case TorConnectStage.Disabled: + return TorConnectState.Disabled; + case TorConnectStage.Loading: + return TorConnectState.Initial; + case TorConnectStage.Start: + case TorConnectStage.Offline: + case TorConnectStage.ChooseRegion: + case TorConnectStage.RegionNotFound: + case TorConnectStage.ConfirmRegion: + case TorConnectStage.FinalError: + return TorConnectState.Configuring; + case TorConnectStage.Bootstrapping: + if ( + this._bootstrapTrigger === TorConnectStage.Start || + this._bootstrapTrigger === TorConnectStage.Offline + ) { + return TorConnectState.Bootstrapping; + } + return TorConnectState.AutoBootstrapping; + case TorConnectStage.Bootstrapped: + return TorConnectState.Bootstrapped; + } + lazy.logger.error(`Unknown state at stage ${this._stageName}`); + return null; + }, + + /** + * Get a map of all region codes and their localized names. + * + * @returns {object} - The region names in the current locale. + */ + getRegionNames() { + if (!this._regionNames) { + this._regionNames = {}; + const codes = Services.intl.getAvailableLocaleDisplayNames("region"); + // Get the localised names, for the current app locale. + const names = Services.intl.getRegionDisplayNames(undefined, codes); + for (let i = 0; i < codes.length; i++) { + this._regionNames[codes[i]] = names[i]; + } + } + return structuredClone(this._regionNames); + }, + + /** + * Whether the Bootstrapping process has ever failed, not including being + * cancelled or being offline. + * + * The value may change with TorConnectTopics.StageChanged. + * + * @type {boolean} + */ + get potentiallyBlocked() { + return this._potentiallyBlocked; + }, + + /** + * Ensure that we are not disabled. + */ + _ensureEnabled() { + if (!this.enabled || this._stageName === TorConnectStage.Disabled) { + throw new Error("Unexpected Disabled stage for user method"); + } + }, + + /** + * Signal an error to listeners. + * + * @param {Error} error - The error. + */ + _signalError(error) { + // TODO: Replace this method with _setError without any signalling when + // pages have switched to stage. + // Currently it simulates the old behaviour for about:torconnect. + lazy.logger.debug("Signalling error", error); + + if (error instanceof lazy.TorBootstrapError) { + error = new TorConnectError(TorConnectError.BootstrapError, error); + } else if (!(error instanceof TorConnectError)) { + error = new TorConnectError(TorConnectError.ExternalError, error); + } + this._errorDetails = error; + + // Temporarily set an error state for listeners. + // We send the Error signal before the "StateChange" signal. + // Expected on android `onBootstrapError` to set lastKnownError. + // Expected in about:torconnect to set the error codes and internet status + // *before* the StateChange signal. + this._isErrorState = true; + Services.obs.notifyObservers(error, TorConnectTopics.Error); + Services.obs.notifyObservers( + { state: this.state }, + TorConnectTopics.StateChange + ); + this._isErrorState = false; + }, + + /** + * Add simulation options to the bootstrap request. + * + * @param {BootstrapOptions} bootstrapOptions - The options to add to. + * @param {string} [regionCode] - The region code being used. + */ + _addSimulateOptions(bootstrapOptions, regionCode) { + if (this.simulateBootstrapOptions.simulateCensorship) { + bootstrapOptions.simulateCensorship = true; + } + if (this.simulateBootstrapOptions.simulateDelay) { + bootstrapOptions.simulateDelay = + this.simulateBootstrapOptions.simulateDelay; + } + if (this.simulateBootstrapOptions.simulateMoatResponse) { + bootstrapOptions.simulateMoatResponse = + this.simulateBootstrapOptions.simulateMoatResponse; + } + + const censorshipLevel = Services.prefs.getIntPref( + TorConnectPrefs.censorship_level, + 0 + ); + if (censorshipLevel > 0 && !bootstrapOptions.simulateDelay) { + bootstrapOptions.simulateDelay = 1500; + } + if (censorshipLevel === 1) { + // Bootstrap fails, but auto-bootstrap does not. + if (!regionCode) { + bootstrapOptions.simulateCensorship = true; + } + } else if (censorshipLevel === 2) { + // Bootstrap fails. Auto-bootstrap fails with ConfirmRegion when using + // auto-detect region, but succeeds otherwise. + if (!regionCode) { + bootstrapOptions.simulateCensorship = true; + } + if (regionCode === "automatic") { + bootstrapOptions.simulateCensorship = true; + bootstrapOptions.simulateMoatResponse = { + country: "fi", + bridgesList: [ + { source: 0, builtin_type: "obfs4" }, + { source: 0, builtin_type: "snowflake" }, + ], + }; + } + } else if (censorshipLevel === 3) { + // Bootstrap and auto-bootstrap fail. + bootstrapOptions.simulateCensorship = true; + bootstrapOptions.simulateMoatResponse = { + country: null, + bridgesList: [], + }; + } + }, + + /** + * Confirm that a bootstrapping can take place, and whether the given values + * are valid. + * + * @param {string} [regionCode] - The region code passed in. + * + * @returns {boolean} whether bootstrapping can proceed. + */ + _confirmBootstrapping(regionCode) { + this._ensureEnabled(); + + if (this._bootstrapAttempt) { + lazy.logger.warn( + "Already have an ongoing bootstrap attempt." + + ` Ignoring request with ${regionCode}.` + ); + return false; + } + + const currentStage = this._stageName; + + if (regionCode) { + if (!this.canBeginAutoBootstrap) { + lazy.logger.warn( + `Cannot begin auto bootstrap in stage ${currentStage}` + ); + return false; + } + if ( + regionCode === "automatic" && + currentStage !== TorConnectStage.ChooseRegion + ) { + lazy.logger.warn("Auto bootstrap is missing an explicit regionCode"); + return false; + } + return true; + } + + if (!this.canBeginNormalBootstrap) { + lazy.logger.warn( + `Cannot begin normal bootstrap in stage ${currentStage}` + ); + return false; + } + + return true; + }, + + /** + * Begin a bootstrap attempt. + * + * @param {string} [regionCode] - An optional region code string to use, or + * "automatic" to automatically determine the region. If given, will start + * an auto-bootstrap attempt. + */ + async beginBootstrapping(regionCode) { + await this._beginBootstrappingInternal(regionCode, false); + }, + + /** + * Begin a bootstrap attempt. + * + * @param {string} [regionCode] - An optional region code string to use, or + * "automatic" to automatically determine the region. If given, will start + * an auto-bootstrap attempt. + * @param {boolean} isQuickstart - Whether this was triggered by the + * quickstart option. + */ + async _beginBootstrappingInternal(regionCode, isQuickstart) { + lazy.logger.debug("TorConnect.beginBootstrapping()"); + + if (!this._confirmBootstrapping(regionCode)) { + return; + } + + const beginStage = this._stageName; + const bootstrapOptions = { regionCode }; + const bootstrapAttempt = regionCode + ? new AutoBootstrapAttempt() + : new BootstrapAttempt(); + + this._addSimulateOptions(bootstrapOptions, regionCode); + + // NOTE: The only `await` in this method is for `bootstrapAttempt.run`. + // Moreover, we returned early if `_bootstrapAttempt` was non-`null`. + // Therefore, the method is effectively "locked" by `_bootstrapAttempt`, so + // there should only ever be one caller at a time. + + if (regionCode) { + // Set the default to what the user chose. + this._defaultRegion = regionCode; + } else { + // Reset the default region to show in the UI. + this._defaultRegion = "automatic"; + } + this._requestedStage = null; + this._bootstrapTrigger = beginStage; + this._isQuickstart = isQuickstart; + this._setStage(TorConnectStage.Bootstrapping); + this._bootstrapAttempt = bootstrapAttempt; + + let error = null; + let result = null; + try { + result = await bootstrapAttempt.run(progress => { + this._bootstrappingStatus.progress = progress; + lazy.logger.info(`Bootstrapping ${progress}% complete`); + this._notifyBootstrapProgress(); + }, bootstrapOptions); + } catch (err) { + error = err; + } + + const requestedStage = this._requestedStage; + this._requestedStage = null; + this._bootstrapTrigger = null; + this._isQuickstart = false; + this._bootstrapAttempt = null; + + if (bootstrapAttempt.detectedRegion) { + this._defaultRegion = bootstrapAttempt.detectedRegion; + } + + if (result === "complete") { + // Reset tryAgain, potentiallyBlocked and errorDetails in case the tor + // process exists later on. + this._tryAgain = false; + this._potentiallyBlocked = false; + this._errorDetails = null; + // Re-enable quickstart for future sessions. + Services.prefs.setBoolPref(TorConnectPrefs.prompt_at_startup, false); + + if (requestedStage) { + lazy.logger.warn( + `Ignoring ${requestedStage} request since we are bootstrapped` + ); + } + this._setStage(TorConnectStage.Bootstrapped); + Services.obs.notifyObservers(null, TorConnectTopics.BootstrapComplete); + + // Now call the postBootstrapped method. We do this after changing the + // stage to ensure that AutoBootstrapAttempt.postBootstrapped call to + // saveTemporaryBridges does not trigger SettingsChanged too early and + // cancel itself. + bootstrapAttempt.postBootstrapped(); + return; + } + + if (requestedStage) { + lazy.logger.debug("Ignoring bootstrap result", result, error); + this._setStage(requestedStage); + return; + } + + if (error) { + lazy.logger.info("Bootstrap attempt error", error); + this._tryAgain = true; + + if (error instanceof lazy.TorProviderInitError) { + // Treat like TorProviderTopics.ProcessExited. We expect a user + // notification when this happens. + // Treat a failure as a possibly broken configuration. + // So, prevent quickstart at the next start. + Services.prefs.setBoolPref(TorConnectPrefs.prompt_at_startup, true); + lazy.logger.info( + "Starting again since the tor provider failed to initialise" + ); + this._setStage(TorConnectStage.Start); + return; + } + + if ( + (beginStage === TorConnectStage.Start || + beginStage === TorConnectStage.Offline) && + (this.internetStatus === InternetStatus.Offline || this.simulateOffline) + ) { + // If we are currently offline, we assume the bootstrap error is caused + // by a general internet connection problem, so we show an "Offline" + // stage instead. + // NOTE: In principle, we may determine that we are offline prior to the + // bootstrap being thrown, but we do not want to cancel a bootstrap + // attempt prematurely in case the offline state is intermittent or + // incorrect. + this._signalError(new TorConnectError(TorConnectError.Offline)); + this._setStage(TorConnectStage.Offline); + return; + } + + this._potentiallyBlocked = true; + // Disable quickstart until we have a successful bootstrap. + Services.prefs.setBoolPref(TorConnectPrefs.prompt_at_startup, true); + + this._signalError(error); + + let errorStage = TorConnectStage.FinalError; + + switch (beginStage) { + case TorConnectStage.Start: + case TorConnectStage.Offline: + if (error instanceof lazy.TorBootstrapError) { + errorStage = TorConnectStage.ChooseRegion; + } + // Else, some other unexpected error type. Skip straight to the + // "FinalError". See tor-browser#43488. + break; + case TorConnectStage.ChooseRegion: + // TODO: Handle a Moat error of the type + // DomainFrontRequestNetworkError to show a different stage. See + // tor-browser#43569. + if ( + regionCode === "automatic" && + error instanceof TorConnectError && + (error.code === TorConnectError.AllSettingsFailed || + error.code === TorConnectError.CannotDetermineCountry || + error.code === TorConnectError.NoSettingsForCountry) + ) { + // The automatic region failed. + errorStage = bootstrapAttempt.detectedRegion + ? TorConnectStage.ConfirmRegion + : TorConnectStage.RegionNotFound; + } + // Else, not automatic. Go straight to the final error since the user + // is unlikely to succeed re-selecting the same region and it would be + // unexpected for the user to select a different region. + // See tor-browser#42550. + // Else, some other error type. Skip straight to the "FinalError". See + // tor-browser#43488. + break; + } + this._setStage(errorStage); + return; + } + + // Bootstrap was cancelled. + if (result !== "cancelled") { + lazy.logger.error(`Unexpected bootstrap result`, result); + } + + // TODO: Remove this Offline hack when pages use "stage". + if (beginStage === TorConnectStage.Offline) { + // Re-send the "Offline" error to push the pages back to "Offline". + this._signalError(new TorConnectError(TorConnectError.Offline)); + } + + // Return to the previous stage. + this._setStage(beginStage); + }, + + /** + * Cancel an ongoing bootstrap attempt. + */ + cancelBootstrapping() { + lazy.logger.debug("TorConnect.cancelBootstrapping()"); + + this._ensureEnabled(); + + if (!this._bootstrapAttempt) { + lazy.logger.warn("No bootstrap attempt to cancel"); + return; + } + + this._bootstrapAttempt.cancel(); + }, + + /** + * Request the transition to the given stage. + * + * If we are bootstrapping, it will be cancelled and the stage will be + * transitioned to when it resolves. Otherwise, we will switch to the stage + * immediately. + * + * @param {string} stage - The stage to request. + * @param {boolean} [overrideBootstrapped=false] - Whether the request can + * override the "Bootstrapped" stage. + */ + _makeStageRequest(stage, overrideBootstrapped = false) { + lazy.logger.debug(`Request for stage ${stage}`); + + this._ensureEnabled(); + + if (stage === this._stageName) { + lazy.logger.info(`Ignoring request for current stage ${stage}`); + return; + } + if ( + !overrideBootstrapped && + this._stageName === TorConnectStage.Bootstrapped + ) { + lazy.logger.warn(`Cannot move to ${stage} when bootstrapped`); + return; + } + if (this._stageName === TorConnectStage.Loading) { + if (stage === TorConnectStage.Start) { + // Will transition to "Start" stage when loading completes. + lazy.logger.info("Still in the Loading stage"); + } else { + lazy.logger.warn(`Cannot move to ${stage} when Loading`); + } + return; + } + + if (!this._bootstrapAttempt) { + // Transition immediately. + this._setStage(stage); + return; + } + + if (this._requestedStage === stage) { + lazy.logger.info(`Already requesting stage ${stage}`); + return; + } + if (this._requestedStage) { + lazy.logger.warn( + `Overriding request for ${this._requestedStage} with ${stage}` + ); + } + // Move to stage *after* bootstrap completes. + this._requestedStage = stage; + this._bootstrapAttempt?.cancel(); + }, + + /** + * Restart the TorConnect stage to the start. + */ + startAgain() { + this._makeStageRequest(TorConnectStage.Start); + }, + + /** + * Set the stage to be "ChooseRegion". + */ + chooseRegion() { + if (!this._potentiallyBlocked) { + lazy.logger.error("chooseRegion request before getting an error"); + return; + } + // NOTE: The ChooseRegion stage needs _errorDetails to be displayed in + // about:torconnect. The _potentiallyBlocked condition should be + // sufficient to ensure this. + this._makeStageRequest(TorConnectStage.ChooseRegion); + }, + + /** + * Get the list of regions that Moat has settings for. + * + * @returns {string[]} - The list of region codes. + */ + async getFrequentRegions() { + return this._moatRegionsPromise ?? []; + }, +}; diff --git a/toolkit/modules/TorSettings.sys.mjs b/toolkit/modules/TorSettings.sys.mjs @@ -0,0 +1,1570 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + TorLauncherUtil: "resource://gre/modules/TorLauncherUtil.sys.mjs", + Lox: "resource://gre/modules/Lox.sys.mjs", + LoxTopics: "resource://gre/modules/Lox.sys.mjs", + TorParsers: "resource://gre/modules/TorParsers.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => { + return console.createInstance({ + maxLogLevelPref: "browser.torsettings.log_level", + prefix: "TorSettings", + }); +}); + +/* TorSettings observer topics */ +export const TorSettingsTopics = Object.freeze({ + Ready: "torsettings:ready", + SettingsChanged: "torsettings:settings-changed", + ApplyError: "torsettings:apply-error", +}); + +/* Prefs used to store settings in TorBrowser prefs */ +const TorSettingsPrefs = Object.freeze({ + // NOTE: torbrowser.settings.quickstart.enabled used to be managed by + // TorSettings but was moved to TorConnect.quickstart in tor-browser#41921. + bridges: { + /* bool: does tor use bridges */ + enabled: "torbrowser.settings.bridges.enabled", + /* int: See TorBridgeSource */ + source: "torbrowser.settings.bridges.source", + /* string: output of crypto.randomUUID() */ + lox_id: "torbrowser.settings.bridges.lox_id", + /* string: obfs4|meek|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", + /* See TorProxyType */ + 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", + }, +}); + +export const TorBridgeSource = Object.freeze({ + Invalid: -1, + BuiltIn: 0, + BridgeDB: 1, + UserProvided: 2, + Lox: 3, +}); + +export const TorProxyType = Object.freeze({ + Invalid: -1, + Socks4: 0, + Socks5: 1, + HTTPS: 2, +}); + +/** + * Split a blob of bridge lines into an array with single lines. + * Lines are delimited by \r\n or \n and each bridge string can also optionally + * have 'bridge' at the beginning. + * We split the text by \r\n, we trim the lines, remove the bridge prefix. + * + * @param {string} bridgeLines The text with the lines + * @returns {string[]} An array where each bridge line is an item + */ +function splitBridgeLines(bridgeLines) { + // Split on the newline and for each bridge string: trim, remove starting + // 'bridge' string. + // Replace whitespace with standard " ". + // NOTE: We only remove the bridge string part if it is followed by a + // non-whitespace. + return bridgeLines.split(/\r?\n/).map(val => + val + .trim() + .replace(/^bridge\s+(\S)/i, "$1") + .replace(/\s+/, " ") + ); +} + +/** + * @typedef {object} BridgeValidationResult + * + * @property {integer[]} errorLines - The lines that contain errors. Counting + * from 1. + * @property {boolean} empty - Whether the given string contains no bridges. + * @property {string[]} validBridges - The valid bridge lines found. + */ +/** + * Validate the given bridge lines. + * + * @param {string} bridgeLines - The bridge lines to validate, separated by + * newlines. + * + * @returns {BridgeValidationResult} + */ +export function validateBridgeLines(bridgeLines) { + let empty = true; + const errorLines = []; + const validBridges = []; + for (const [index, bridge] of splitBridgeLines(bridgeLines).entries()) { + if (!bridge) { + // Empty line. + continue; + } + empty = false; + try { + // TODO: Have a more comprehensive validation parser. + lazy.TorParsers.parseBridgeLine(bridge); + } catch { + errorLines.push(index + 1); + continue; + } + validBridges.push(bridge); + } + return { empty, errorLines, validBridges }; +} + +/** + * Return a shuffled (Fisher-Yates) copy of an array. + * + * @template T + * @param {T[]} array + * @returns {T[]} + */ +function arrayShuffle(array) { + array = [...array]; + 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; + } + return array; +} + +/** + * @typedef {object} TorBridgeSettings + * + * Represents the Tor bridge settings. + * + * @property {boolean} enabled - Whether bridges are enabled. + * @property {integer} source - The source of bridges. One of the values in + * `TorBridgeSource`. + * @property {string} lox_id - The ID of the lox credentials to get bridges for. + * Or "" if not using a `Lox` `source`. + * @property {string} builtin_type - The name of the built-in bridge type. Or "" + * if not using a `BuiltIn` `source`. + * @property {string[]} bridge_strings - The bridge lines to be passed to the + * provider. Should be empty if and only if the `source` is `Invalid`. + */ + +/** + * @typedef {object} TorProxySettings + * + * Represents the Tor proxy settings. + * + * @property {boolean} enabled - Whether the proxy should be enabled. + * @property {integer} type - The proxy type. One of the values in + * `TorProxyType`. + * @property {string} address - The proxy address, or "" if the proxy is not + * being used. + * @property {integer} port - The proxy port, or 0 if the proxy is not being + * used. + * @property {string} username - The proxy user name, or "" if this is not + * needed. + * @property {string} password - The proxy password, or "" if this is not + * needed. + */ + +/** + * @typedef {object} TorFirewallSettings + * + * Represents the Tor firewall settings. + * + * @property {boolean} enabled - Whether the firewall settings should be + * enabled. + * @property {integer[]} allowed_ports - The list of ports that are allowed. + */ + +/** + * @typedef {object} TorCombinedSettings + * + * A combination of Tor settings. + * + * @property {TorBridgeSettings} bridges - The bridge settings. + * @property {TorProxySettings} proxy - The proxy settings. + * @property {TorFirewallSettings} firewall - The firewall settings. + */ + +/* TorSettings module */ + +/** + * The implementation for the global `TorSettings` object. + */ +class TorSettingsImpl { + /** + * The default settings to use. + * + * @type {TorCombinedSettings} + */ + #defaultSettings = { + bridges: { + enabled: false, + source: TorBridgeSource.Invalid, + lox_id: "", + builtin_type: "", + bridge_strings: [], + }, + proxy: { + enabled: false, + type: TorProxyType.Invalid, + address: "", + port: 0, + username: "", + password: "", + }, + firewall: { + enabled: false, + allowed_ports: [], + }, + }; + + /** + * The underlying settings values. + * + * @type {TorCombinedSettings} + */ + #settings = structuredClone(this.#defaultSettings); + + /** + * The last successfully applied settings for the current `TorProvider`, if + * any. + * + * NOTE: Should only be written to within `#applySettings`. + * + * @type {{ + * bridges: ?TorBridgeSettings, + * proxy: ?TorProxySettings, + * firewall: ?TorFirewallSettings + * }} + */ + #successfulSettings = { bridges: null, proxy: null, firewall: null }; + + /** + * Whether temporary bridge settings have been applied to the current + * `TorProvider`. + * + * @type {boolean} + */ + #temporaryBridgesApplied = false; + + /** + * @typedef {TorSettingsApplyError} + * + * @property {boolean} canUndo - Whether the latest error can be "undone". + * When this is `false`, the TorProvider will be using its default values + * instead. + */ + + /** + * A summary of the latest failures to apply our settings, if any. + * + * NOTE: Should only be written to within `#applySettings`. + * + * @type {{ + * bridges: ?TorSettingsApplyError, + * proxy: ?TorSettingsApplyError, + * firewall: ?TorSettingsApplyError, + * }} + */ + #applyErrors = { bridges: null, proxy: null, firewall: null }; + + /** + * Get the latest failure for the given setting, if any. + * + * @param {string} group - The settings to get the error details for. + * + * @returns {?TorSettingsApplyError} - The error details, if any. + */ + getApplyError(group) { + return structuredClone(this.#applyErrors[group]); + } + + /** + * Temporary bridge settings to apply instead of #settings.bridges. + * + * @type {?TorBridgeSettings} + */ + #temporaryBridgeSettings = null; + + /** + * The recommended pluggable transport. + * + * @type {string} + */ + #recommendedPT = ""; + + /** + * The bridge lines for built-in bridges. + * Keys are pluggable transports, and values are arrays of bridge lines. + * + * @type {object} + */ + #builtinBridges = {}; + + /** + * A promise that resolves once we are initialized, or throws if there was an + * initialization error. + * + * @type {Promise} + */ + #initializedPromise; + /** + * Resolve callback of the initializedPromise. + */ + #initComplete; + /** + * Reject callback of the initializedPromise. + */ + #initFailed; + /** + * Tell whether the initializedPromise has been resolved. + * We keep this additional member to avoid making everything async. + * + * @type {boolean} + */ + #initialized = false; + + /** + * Whether uninit cleanup has been called. + * + * @type {boolean} + */ + #uninitCalled = false; + + /** + * Whether Lox was initialized. + * + * @type {boolean} + */ + #initializedLox = false; + + /** + * Whether observers were initialized. + * + * @type {boolean} + */ + #initializedObservers = false; + + constructor() { + this.#initializedPromise = new Promise((resolve, reject) => { + this.#initComplete = resolve; + this.#initFailed = reject; + }); + + // Add some read-only getters for the #settings object. + // E.g. TorSetting.#settings.bridges.source is exposed publicly as + // TorSettings.bridges.source. + for (const groupname in this.#settings) { + const publicGroup = {}; + for (const name in this.#settings[groupname]) { + // Public group only has a getter for the property. + Object.defineProperty(publicGroup, name, { + get: () => { + this.#checkIfInitialized(); + return structuredClone(this.#settings[groupname][name]); + }, + set: () => { + throw new Error( + `TorSettings.${groupname}.${name} cannot be set directly` + ); + }, + }); + } + // The group object itself should not be writable. + Object.preventExtensions(publicGroup); + Object.defineProperty(this, groupname, { + writable: false, + value: publicGroup, + }); + } + } + + /** + * The proxy URI for the current settings, or `null` if no proxy is + * configured. + * + * @type {?string} + */ + get proxyUri() { + const { type, address, port, username, password } = this.#settings.proxy; + switch (type) { + case TorProxyType.Socks4: + return `socks4a://${address}:${port}`; + case TorProxyType.Socks5: + if (username) { + return `socks5://${username}:${password}@${address}:${port}`; + } + return `socks5://${address}:${port}`; + case TorProxyType.HTTPS: + if (username) { + return `http://${username}:${password}@${address}:${port}`; + } + return `http://${address}:${port}`; + } + return null; + } + + /** + * Verify a port number is within bounds. + * + * @param {integer} val - The value to verify. + * @returns {boolean} - Whether the port is within range. + */ + validPort(val) { + return Number.isInteger(val) && val >= 1 && val <= 65535; + } + + /** + * Verify that some SOCKS5 credentials are valid. + * + * @param {string} username - The SOCKS5 username. + * @param {string} password - The SOCKS5 password. + * @returns {boolean} - Whether the credentials are valid. + */ + validSocks5Credentials(username, password) { + if (!username && !password) { + // Both empty is valid. + return true; + } + for (const val of [username, password]) { + if (typeof val !== "string") { + return false; + } + const byteLen = new TextEncoder().encode(val).length; + if (byteLen < 1 || byteLen > 255) { + return false; + } + } + return true; + } + + /** + * Test whether two arrays have equal members and order. + * + * @param {Array} val1 - The first array to test. + * @param {Array} val2 - The second array to compare against. + * + * @returns {boolean} - Whether the two arrays are equal. + */ + #arrayEqual(val1, val2) { + if (val1.length !== val2.length) { + return false; + } + return val1.every((v, i) => v === val2[i]); + } + + /** + * Return the bridge lines associated to a certain pluggable transport. + * + * @param {string} pt The pluggable transport to return the lines for + * @returns {string[]} The bridge lines in random order + */ + #getBuiltinBridges(pt) { + return this.#builtinBridges[pt] ?? []; + } + + /** + * Whether this module is enabled. + * + * @type {boolean} + */ + get enabled() { + return lazy.TorLauncherUtil.shouldStartAndOwnTor; + } + + /** + * Load or init our settings. + */ + async init() { + if (this.#initialized) { + lazy.logger.warn("Called init twice."); + await this.#initializedPromise; + return; + } + try { + await this.#initInternal(); + this.#initialized = true; + this.#initComplete(); + Services.obs.notifyObservers(null, TorSettingsTopics.Ready); + } catch (e) { + this.#initFailed(e); + throw e; + } + } + + /** + * The actual implementation of the initialization, which is wrapped to make + * it easier to update initializatedPromise. + */ + async #initInternal() { + if (!this.enabled || this.#uninitCalled) { + // Nothing to do. + return; + } + + try { + const req = await fetch("chrome://global/content/pt_config.json"); + const config = await req.json(); + lazy.logger.debug("Loaded pt_config.json", config); + if ("meek-azure" in config.bridges) { + // Convert the meek-azure name to meek. tor-browser#44068. + // NOTE: no need to convert recommendedDefault since it is not meek. + lazy.logger.debug("Converting pt_config type from meek-azure to meek"); + config.bridges.meek = config.bridges["meek-azure"]; + delete config.bridges["meek-azure"]; + } + this.#recommendedPT = config.recommendedDefault; + this.#builtinBridges = config.bridges; + for (const type in this.#builtinBridges) { + // Shuffle so that Tor Browser users do not all try the built-in bridges + // in the same order. + // Only do this once per session. In particular, we don't re-shuffle if + // changeSettings is called with the same bridges.builtin_type value. + this.#builtinBridges[type] = arrayShuffle(this.#builtinBridges[type]); + } + } catch (e) { + lazy.logger.error("Could not load the built-in PT config.", e); + } + + // `uninit` may have been called whilst we awaited pt_config. + if (this.#uninitCalled) { + lazy.logger.warn("unint was called before init completed."); + return; + } + + // Initialize this before loading from prefs because we need Lox initialized + // before any calls to Lox.getBridges(). + if (!lazy.TorLauncherUtil.isAndroid) { + try { + // Set as initialized before calling to ensure it is cleaned up by our + // `uninit` method. + this.#initializedLox = true; + await lazy.Lox.init(); + } catch (e) { + lazy.logger.error("Could not initialize Lox.", e); + } + } + + // `uninit` may have been called whilst we awaited Lox.init. + if (this.#uninitCalled) { + lazy.logger.warn("unint was called before init completed."); + return; + } + + this.#loadFromPrefs(); + // We do not pass on the loaded settings to the TorProvider yet. Instead + // TorProvider will ask for these once it has initialised. + + Services.obs.addObserver(this, lazy.LoxTopics.UpdateBridges); + this.#initializedObservers = true; + + lazy.logger.info("Ready"); + } + + /** + * Unload or uninit our settings. + */ + async uninit() { + if (this.#uninitCalled) { + lazy.logger.warn("Called uninit twice"); + return; + } + + this.#uninitCalled = true; + // NOTE: We do not reset #initialized to false because we want it to remain + // in place for external callers, and we do not want `#initInternal` to be + // re-entered. + + if (this.#initializedObservers) { + Services.obs.removeObserver(this, lazy.LoxTopics.UpdateBridges); + } + if (this.#initializedLox) { + await lazy.Lox.uninit(); + } + } + + observe(subject, topic) { + switch (topic) { + case lazy.LoxTopics.UpdateBridges: + if ( + this.#settings.bridges.lox_id && + this.#settings.bridges.source === TorBridgeSource.Lox + ) { + // Re-trigger the call to lazy.Lox.getBridges. + // FIXME: This can cancel a bootstrap. tor-browser#43991. + this.changeSettings({ + bridges: { + source: TorBridgeSource.Lox, + lox_id: this.#settings.bridges.lox_id, + }, + }); + } + break; + } + } + + /** + * Check whether the module is enabled and successfully initialized, and throw + * if it is not. + */ + #checkIfInitialized() { + if (!this.enabled) { + throw new Error("TorSettings is not enabled"); + } + if (!this.#initialized) { + lazy.logger.trace("Not initialized code path."); + throw new Error( + "TorSettings has not been initialized yet, or its initialization failed" + ); + } + } + + /** + * Tell whether TorSettings has been successfully initialized. + * + * @returns {boolean} + */ + get initialized() { + return this.#initialized; + } + + /** + * A promise that resolves once we are initialized, or throws if there was an + * initialization error. + * + * @type {Promise} + */ + get initializedPromise() { + return this.#initializedPromise; + } + + /** + * Load our settings from prefs. + */ + #loadFromPrefs() { + lazy.logger.debug("loadFromPrefs()"); + + /* Bridges */ + const bridges = {}; + bridges.enabled = Services.prefs.getBoolPref( + TorSettingsPrefs.bridges.enabled, + false + ); + bridges.source = Services.prefs.getIntPref( + TorSettingsPrefs.bridges.source, + TorBridgeSource.Invalid + ); + switch (bridges.source) { + case TorBridgeSource.BridgeDB: + case TorBridgeSource.UserProvided: + bridges.bridge_strings = Services.prefs + .getBranch(TorSettingsPrefs.bridges.bridge_strings) + .getChildList("") + .map(pref => + Services.prefs.getStringPref( + `${TorSettingsPrefs.bridges.bridge_strings}${pref}` + ) + ); + break; + case TorBridgeSource.BuiltIn: { + // bridge_strings is set via builtin_type. + let builtinType = Services.prefs.getStringPref( + TorSettingsPrefs.bridges.builtin_type, + "" + ); + if (builtinType === "meek-azure") { + lazy.logger.debug( + "Converting builtin-bridge setting value from meek-azure to meek" + ); + builtinType = "meek"; + // Store the new value. + Services.prefs.setStringPref( + TorSettingsPrefs.bridges.builtin_type, + builtinType + ); + } + bridges.builtin_type = builtinType; + break; + } + case TorBridgeSource.Lox: + // bridge_strings is set via lox id. + bridges.lox_id = Services.prefs.getStringPref( + TorSettingsPrefs.bridges.lox_id, + "" + ); + break; + } + try { + this.#fixupBridgeSettings(bridges); + this.#settings.bridges = bridges; + } catch (error) { + lazy.logger.error("Loaded bridge preferences failed", error); + // Keep the default #settings.bridges. + } + + /* Proxy */ + const proxy = {}; + proxy.enabled = Services.prefs.getBoolPref( + TorSettingsPrefs.proxy.enabled, + false + ); + if (proxy.enabled) { + proxy.type = Services.prefs.getIntPref( + TorSettingsPrefs.proxy.type, + TorProxyType.Invalid + ); + proxy.address = Services.prefs.getStringPref( + TorSettingsPrefs.proxy.address, + "" + ); + proxy.port = Services.prefs.getIntPref(TorSettingsPrefs.proxy.port, 0); + proxy.username = Services.prefs.getStringPref( + TorSettingsPrefs.proxy.username, + "" + ); + proxy.password = Services.prefs.getStringPref( + TorSettingsPrefs.proxy.password, + "" + ); + } + try { + this.#fixupProxySettings(proxy); + this.#settings.proxy = proxy; + } catch (error) { + lazy.logger.error("Loaded proxy preferences failed", error); + // Keep the default #settings.proxy. + } + + /* Firewall */ + const firewall = {}; + firewall.enabled = Services.prefs.getBoolPref( + TorSettingsPrefs.firewall.enabled, + false + ); + if (firewall.enabled) { + firewall.allowed_ports = Services.prefs + .getStringPref(TorSettingsPrefs.firewall.allowed_ports, "") + .split(",") + .filter(p => p.trim()) + .map(p => parseInt(p, 10)); + } + try { + this.#fixupFirewallSettings(firewall); + this.#settings.firewall = firewall; + } catch (error) { + lazy.logger.error("Loaded firewall preferences failed", error); + // Keep the default #settings.firewall. + } + } + + /** + * Save our bridge settings. + */ + #saveBridgeSettings() { + lazy.logger.debug("Saving bridge settings"); + + Services.prefs.setBoolPref( + TorSettingsPrefs.bridges.enabled, + this.#settings.bridges.enabled + ); + Services.prefs.setIntPref( + TorSettingsPrefs.bridges.source, + this.#settings.bridges.source + ); + Services.prefs.setStringPref( + TorSettingsPrefs.bridges.builtin_type, + this.#settings.bridges.builtin_type + ); + Services.prefs.setStringPref( + TorSettingsPrefs.bridges.lox_id, + this.#settings.bridges.lox_id + ); + // 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 ( + this.#settings.bridges.source !== TorBridgeSource.Lox && + this.#settings.bridges.source !== TorBridgeSource.BuiltIn + ) { + this.#settings.bridges.bridge_strings.forEach((string, index) => { + Services.prefs.setStringPref( + `${TorSettingsPrefs.bridges.bridge_strings}.${index}`, + string + ); + }); + } + } + + /** + * Save our proxy settings. + */ + #saveProxySettings() { + lazy.logger.debug("Saving proxy settings"); + + Services.prefs.setBoolPref( + TorSettingsPrefs.proxy.enabled, + this.#settings.proxy.enabled + ); + if (this.#settings.proxy.enabled) { + Services.prefs.setIntPref( + TorSettingsPrefs.proxy.type, + this.#settings.proxy.type + ); + Services.prefs.setStringPref( + TorSettingsPrefs.proxy.address, + this.#settings.proxy.address + ); + Services.prefs.setIntPref( + TorSettingsPrefs.proxy.port, + this.#settings.proxy.port + ); + Services.prefs.setStringPref( + TorSettingsPrefs.proxy.username, + this.#settings.proxy.username + ); + Services.prefs.setStringPref( + TorSettingsPrefs.proxy.password, + this.#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); + } + } + + /** + * Save our firewall settings. + */ + #saveFirewallSettings() { + lazy.logger.debug("Saving firewall settings"); + + Services.prefs.setBoolPref( + TorSettingsPrefs.firewall.enabled, + this.#settings.firewall.enabled + ); + if (this.#settings.firewall.enabled) { + Services.prefs.setStringPref( + TorSettingsPrefs.firewall.allowed_ports, + this.#settings.firewall.allowed_ports.join(",") + ); + } else { + Services.prefs.clearUserPref(TorSettingsPrefs.firewall.allowed_ports); + } + } + + /** + * A blocker promise for the #applySettings method. + * + * Ensures only one active caller to protect the #applyErrors and + * #successfulSettings properties. + * + * @type {?Promise} + */ + #applySettingsTask = null; + + /** + * Push our settings down to the tor provider. + * + * Even though this introduces a circular depdency, it makes the API nicer for + * frontend consumers. + * + * @param {object} apply - The list of settings to apply. + * @param {boolean} [apply.bridges] - Whether to apply our bridge settings. + * @param {boolean} [apply.proxy] - Whether to apply our proxy settings. + * @param {boolean} [apply.firewall] - Whether to apply our firewall settings. + * @param {boolean} [details] - Optional details. + * @param {boolean} [details.useTemporaryBridges] - Whether the caller wants + * to apply temporary bridges. + * @param {boolean} [details.newProvider] - Whether the caller is initialising + * a new `TorProvider`. + */ + async #applySettings(apply, details) { + // Grab this provider before awaiting. + // In particular, if the provider is changed we do not want to switch to + // writing to the new instance. + const providerRef = this.#providerRef; + const provider = providerRef?.deref(); + if (!provider) { + // Wait until setTorProvider is called. + lazy.logger.info("No TorProvider yet"); + return; + } + + // We only want one instance of #applySettings to be running at any given + // time, so we await the previous call first. + // Read and replace #applySettingsTask before we do any async operations. + // I.e. this is effectively an atomic read and replace. + const prevTask = this.#applySettingsTask; + let taskComplete; + ({ promise: this.#applySettingsTask, resolve: taskComplete } = + Promise.withResolvers()); + await prevTask; + + try { + let flush = false; + const errors = []; + + // Test whether the provider is no longer running or has been replaced. + const providerRunning = () => { + return providerRef === this.#providerRef && provider.isRunning; + }; + + lazy.logger.debug("Passing on settings to the provider", apply, details); + + if (details?.newProvider) { + // If we have a new provider we clear our successful settings. + // In particular, the user may have changed their settings several times + // whilst the tor process was not running. In the event of an + // "ApplyError", we want to correctly show to the user that they are now + // using default settings and we do not want to allow them to "undo" + // since the previous successful settings may be out of date. + // NOTE: We do not do this within `setTorProvider` since some other + // caller's `#applySettingsTask` may still be running and writing to + // these values when `setTorProvider` is called. + this.#successfulSettings.bridges = null; + this.#successfulSettings.proxy = null; + this.#successfulSettings.firewall = null; + this.#applyErrors.bridges = null; + this.#applyErrors.proxy = null; + this.#applyErrors.firewall = null; + // Temporary bridges are not applied to the new provider. + this.#temporaryBridgesApplied = false; + } + + for (const group of ["bridges", "proxy", "firewall"]) { + if (!apply[group]) { + continue; + } + + if (!providerRunning()) { + lazy.logger.info("The TorProvider is no longer running"); + // Bail on this task since the old provider should not accept + // settings. setTorProvider will be called for the new provider and + // will already handle applying the same settings. + return; + } + + let usingSettings = true; + if (group === "bridges") { + // Only record successes or failures when using the user settings. + if (this.#temporaryBridgeSettings && !details?.useTemporaryBridges) { + // #temporaryBridgeSettings were written whilst awaiting the + // previous task. Do nothing and allow applyTemporarySettings to + // apply the temporary bridges instead. + lazy.logger.info( + "Not apply bridges since temporary bridges were applied" + ); + continue; + } + if (!this.#temporaryBridgeSettings && details?.useTemporaryBridges) { + // #temporaryBridgeSettings were cleared whilst awaiting the + // previous task. Do nothing and allow changeSettings or + // clearTemporaryBridges to apply the non-temporary bridges + // instead. + lazy.logger.info( + "Not apply temporary bridges since they were cleared" + ); + continue; + } + usingSettings = !details?.useTemporaryBridges; + } + + try { + switch (group) { + case "bridges": { + const bridges = structuredClone( + usingSettings + ? this.#settings.bridges + : this.#temporaryBridgeSettings + ); + + try { + await provider.writeBridgeSettings(bridges); + } catch (e) { + if ( + usingSettings && + this.#temporaryBridgesApplied && + providerRunning() + ) { + lazy.logger.warn( + "Recovering to clear temporary bridges from the provider" + ); + // The TorProvider is still using the temporary bridges. As a + // priority we want to try and restore the TorProvider to the + // state it was in prior to the temporary bridges being + // applied. + const prevBridges = structuredClone( + this.#successfulSettings.bridges ?? + this.#defaultSettings.bridges + ); + try { + await provider.writeBridgeSettings(prevBridges); + this.#temporaryBridgesApplied = false; + } catch (ex) { + lazy.logger.error( + "Failed to clear the temporary bridges from the provider", + ex + ); + } + } + throw e; + } + + if (usingSettings) { + this.#successfulSettings.bridges = bridges; + this.#temporaryBridgesApplied = false; + this.#applyErrors.bridges = null; + flush = true; + } else { + this.#temporaryBridgesApplied = true; + // Do not flush the temporary bridge settings until they are + // saved. + } + break; + } + case "proxy": { + const proxy = structuredClone(this.#settings.proxy); + await provider.writeProxySettings(proxy); + this.#successfulSettings.proxy = proxy; + this.#applyErrors.proxy = null; + flush = true; + break; + } + case "firewall": { + const firewall = structuredClone(this.#settings.firewall); + await provider.writeFirewallSettings(firewall); + this.#successfulSettings.firewall = firewall; + this.#applyErrors.firewall = null; + flush = true; + break; + } + } + } catch (e) { + // Store the error and throw later. + errors.push(e); + if (usingSettings && providerRunning()) { + // Record and signal the error. + // NOTE: We do not signal ApplyError when we fail to apply temporary + // bridges. + this.#applyErrors[group] = { + canUndo: Boolean(this.#successfulSettings[group]), + }; + lazy.logger.debug(`Signalling new ApplyError for ${group}`); + Services.obs.notifyObservers( + { group }, + TorSettingsTopics.ApplyError + ); + } + } + } + if (flush && providerRunning()) { + provider.flushSettings(); + } + if (errors.length) { + lazy.logger.error("Failed to apply settings", errors); + throw new Error(`Failed to apply settings. ${errors.join(". ")}.`); + } + } finally { + // Allow the next caller to proceed. + taskComplete(); + } + } + + /** + * Fixup the given bridges settings to fill in details, establish the correct + * types and clean up. + * + * May throw if there is an error in the given values. + * + * @param {object} bridges - The bridges settings to fix up. + */ + #fixupBridgeSettings(bridges) { + if (!Object.values(TorBridgeSource).includes(bridges.source)) { + throw new Error(`Not a valid bridge source: "${bridges.source}"`); + } + + if ("enabled" in bridges) { + bridges.enabled = Boolean(bridges.enabled); + } + + // Set bridge_strings + switch (bridges.source) { + case TorBridgeSource.UserProvided: + case TorBridgeSource.BridgeDB: + // Only accept an Array for UserProvided and BridgeDB bridge_strings. + break; + case TorBridgeSource.BuiltIn: + bridges.builtin_type = String(bridges.builtin_type); + bridges.bridge_strings = this.#getBuiltinBridges(bridges.builtin_type); + break; + case TorBridgeSource.Lox: + bridges.lox_id = String(bridges.lox_id); + bridges.bridge_strings = lazy.Lox.getBridges(bridges.lox_id); + break; + case TorBridgeSource.Invalid: + bridges.bridge_strings = []; + break; + } + + if ( + !Array.isArray(bridges.bridge_strings) || + bridges.bridge_strings.some(str => typeof str !== "string") + ) { + throw new Error("bridge_strings should be an Array of strings"); + } + + if ( + bridges.source !== TorBridgeSource.Invalid && + !bridges.bridge_strings?.length + ) { + throw new Error( + `Missing bridge_strings for bridge source ${bridges.source}` + ); + } + + if (bridges.source !== TorBridgeSource.BuiltIn) { + bridges.builtin_type = ""; + } + if (bridges.source !== TorBridgeSource.Lox) { + bridges.lox_id = ""; + } + + if (bridges.source === TorBridgeSource.Invalid) { + bridges.enabled = false; + } + } + + /** + * Fixup the given proxy settings to fill in details, establish the correct + * types and clean up. + * + * May throw if there is an error in the given values. + * + * @param {object} proxy - The proxy settings to fix up. + */ + #fixupProxySettings(proxy) { + proxy.enabled = Boolean(proxy.enabled); + if (!proxy.enabled) { + proxy.type = TorProxyType.Invalid; + proxy.address = ""; + proxy.port = 0; + proxy.username = ""; + proxy.password = ""; + return; + } + + if (!Object.values(TorProxyType).includes(proxy.type)) { + throw new Error(`Invalid proxy type: ${proxy.type}`); + } + // Do not allow port value to be 0. + // Whilst Socks4Proxy, Socks5Proxy and HTTPSProxyPort allow you to pass in + // `<address>:0` this will select a default port. Our UI does not indicate + // that `0` maps to a different value, so we disallow it. + if (!this.validPort(proxy.port)) { + throw new Error(`Invalid proxy port: ${proxy.port}`); + } + + switch (proxy.type) { + case TorProxyType.Socks4: + // Never use the username or password. + proxy.username = ""; + proxy.password = ""; + break; + case TorProxyType.Socks5: + if (!this.validSocks5Credentials(proxy.username, proxy.password)) { + throw new Error("Invalid SOCKS5 credentials"); + } + break; + case TorProxyType.HTTPS: + // username and password are both optional. + break; + } + + proxy.address = String(proxy.address); + proxy.username = String(proxy.username); + proxy.password = String(proxy.password); + } + + /** + * Fixup the given firewall settings to fill in details, establish the correct + * types and clean up. + * + * May throw if there is an error in the given values. + * + * @param {object} firewall - The proxy settings to fix up. + */ + #fixupFirewallSettings(firewall) { + firewall.enabled = Boolean(firewall.enabled); + if (!firewall.enabled) { + firewall.allowed_ports = []; + return; + } + + if (!Array.isArray(firewall.allowed_ports)) { + throw new Error("allowed_ports should be an array of ports"); + } + for (const port of firewall.allowed_ports) { + if (!this.validPort(port)) { + throw new Error(`Invalid firewall port: ${port}`); + } + } + // Remove duplicates + firewall.allowed_ports = [...new Set(firewall.allowed_ports)]; + } + + /** + * The current `TorProvider` instance we are using, if any. + * + * @type {?WeakRef<TorProvider>} + */ + #providerRef = null; + + /** + * Called whenever we have a new provider to send settings to. + * + * @param {TorProvider} provider - The provider to apply our settings to. + */ + async setTorProvider(provider) { + lazy.logger.debug("Applying settings to new provider"); + this.#checkIfInitialized(); + + // Use a WeakRef to not keep the TorProvider instance alive. + this.#providerRef = new WeakRef(provider); + // NOTE: We need the caller to pass in the TorProvider instance because + // TorProvider's initialisation waits for this method. In particular, we + // cannot await TorProviderBuilder.build since it would hang! + await this.#applySettings( + { bridges: true, proxy: true, firewall: true }, + { newProvider: true } + ); + } + + /** + * Undo settings that have failed to be applied by restoring the last + * successfully applied settings instead. + * + * @param {string} group - The group to undo the settings for. + */ + async undoFailedSettings(group) { + if (!this.#applyErrors[group]) { + lazy.logger.warn( + `${group} settings have already been successfully replaced.` + ); + return; + } + if (!this.#successfulSettings[group]) { + // Unexpected. + lazy.logger.warn( + `Cannot undo ${group} settings since we have no successful settings.` + ); + return; + } + await this.changeSettings({ [group]: this.#successfulSettings[group] }); + } + + /** + * Clear settings that have failed to be applied by using the default settings + * instead. + * + * @param {string} group - The group to clear the settings for. + */ + async clearFailedSettings(group) { + if (!this.#applyErrors[group]) { + lazy.logger.warn( + `${group} settings have already been successfully replaced.` + ); + return; + } + await this.changeSettings({ [group]: this.#defaultSettings[group] }); + } + + /** + * Change the Tor settings in use. + * + * It is possible to set all settings, or only some sections: + * + * + bridges.enabled can be set individually. + * + bridges.source can be set with a corresponding bridge specification for + * the source (bridge_strings, lox_id, builtin_type). + * + proxy settings can be set as a group. + * + firewall settings can be set a group. + * + * @param {object} newValues - The new setting values that should be changed. + * A subset of the `TorCombinedSettings` object. + */ + async changeSettings(newValues) { + lazy.logger.debug("changeSettings()", newValues); + this.#checkIfInitialized(); + + // Make a structured clone since we change the object and may adopt some of + // the Array values. + newValues = structuredClone(newValues); + + const completeSettings = structuredClone(this.#settings); + const changes = []; + const apply = {}; + + /** + * Change the given setting to a new value. Does nothing if the new value + * equals the old one, otherwise the change will be recorded in `changes`. + * + * @param {string} group - The group name for the property. + * @param {string} prop - The property name within the group. + * @param {any} value - The value to set. + * @param {Function?} equal - A method to test equality between the old and + * new value. Otherwise uses `===` to check equality. + */ + const changeSetting = (group, prop, value, equal = null) => { + const currentValue = this.#settings[group][prop]; + if (equal ? equal(currentValue, value) : currentValue === value) { + return; + } + completeSettings[group][prop] = value; + changes.push(`${group}.${prop}`); + // Apply these settings. + apply[group] = true; + }; + + if ("bridges" in newValues) { + if ("source" in newValues.bridges) { + this.#fixupBridgeSettings(newValues.bridges); + changeSetting("bridges", "source", newValues.bridges.source); + changeSetting( + "bridges", + "bridge_strings", + newValues.bridges.bridge_strings, + this.#arrayEqual + ); + changeSetting("bridges", "lox_id", newValues.bridges.lox_id); + changeSetting( + "bridges", + "builtin_type", + newValues.bridges.builtin_type + ); + } else if ("enabled" in newValues.bridges) { + // Don't need to fixup all the settings, just need to ensure that the + // enabled value is compatible with the current source. + newValues.bridges.enabled = Boolean(newValues.bridges.enabled); + if ( + newValues.bridges.enabled && + completeSettings.bridges.source === TorBridgeSource.Invalid + ) { + throw new Error("Cannot enable bridges without a bridge source."); + } + } + if ("enabled" in newValues.bridges) { + changeSetting("bridges", "enabled", newValues.bridges.enabled); + } + + if (this.#temporaryBridgeSettings && apply.bridges) { + // A change in the bridges settings. + // We want to clear the temporary bridge settings to ensure that they + // cannot be used to overwrite these user-provided settings. + // See tor-browser#41921. + // NOTE: This should also trigger TorConnect to cancel any ongoing + // AutoBootstrap that would have otherwise used these settings. + this.#temporaryBridgeSettings = null; + lazy.logger.warn( + "Cleared temporary bridges since bridge settings were changed" + ); + } + } + + if ("proxy" in newValues) { + // proxy settings have to be set as a group. + this.#fixupProxySettings(newValues.proxy); + changeSetting("proxy", "enabled", Boolean(newValues.proxy.enabled)); + changeSetting("proxy", "type", newValues.proxy.type); + changeSetting("proxy", "address", newValues.proxy.address); + changeSetting("proxy", "port", newValues.proxy.port); + changeSetting("proxy", "username", newValues.proxy.username); + changeSetting("proxy", "password", newValues.proxy.password); + } + + if ("firewall" in newValues) { + // firewall settings have to be set as a group. + this.#fixupFirewallSettings(newValues.firewall); + changeSetting("firewall", "enabled", Boolean(newValues.firewall.enabled)); + changeSetting( + "firewall", + "allowed_ports", + newValues.firewall.allowed_ports, + this.#arrayEqual + ); + } + + // No errors so far, so save and commit. + this.#settings = completeSettings; + // NOTE: We want to avoid overwriting saved preference values unless the + // user actually makes a change in their settings. + // In particular, if we fail to load a setting at startup due to a bug, the + // #settings object for that group will point to the #defaultSettings value + // instead. We do not want to write these #defaultSettings to the user's + // settings unless the user actually makes a change in one of the groups. + // E.g. we do not want a change in the proxy settings to overwrite the + // saved bridge settings. Hence, we only save the groups that have changes. + // See tor-browser#43766. + // NOTE: We could go more fine-grained and only save the preference values + // that actually change. E.g. only save the bridges.enabled pref when the + // user switches the toggle, and leave the bridges.bridge_strings as they + // are. However, at the time of implementation there is no known benefit to + // doing this, since the #defaultSettings will not allow for any changes + // that don't require changing the group entirely. E.g. to change + // bridges.enabled when starting with the #defaultSettings.bridges, + // bridges.bridge_strings must necessarily be set. + if (apply.bridges) { + this.#saveBridgeSettings(); + } + if (apply.proxy) { + this.#saveProxySettings(); + } + if (apply.firewall) { + this.#saveFirewallSettings(); + } + + if (changes.length) { + Services.obs.notifyObservers( + { changes }, + TorSettingsTopics.SettingsChanged + ); + } + + lazy.logger.debug("setSettings result", this.#settings, changes); + + if (apply.bridges || apply.proxy || apply.firewall) { + // After we have sent out the notifications for the changed settings and + // saved the preferences we send the new settings to TorProvider. + await this.#applySettings(apply); + } + } + + /** + * Get a copy of all our settings. + * + * @returns {TorCombinedSettings} A copy of the current settings. + */ + getSettings() { + lazy.logger.debug("getSettings()"); + this.#checkIfInitialized(); + return structuredClone(this.#settings); + } + + /** + * Return an array with the pluggable transports for which we have at least a + * built-in bridge line. + * + * @returns {string[]} An array with PT identifiers + */ + get builtinBridgeTypes() { + this.#checkIfInitialized(); + const types = Object.keys(this.#builtinBridges); + const recommendedIndex = types.indexOf(this.#recommendedPT); + if (recommendedIndex > 0) { + types.splice(recommendedIndex, 1); + types.unshift(this.#recommendedPT); + } + return types; + } + + /** + * Apply some Moat bridges temporarily. + * + * These bridges will not yet be saved to settings. + * + * @param {MoatBridges} bridges - The bridges to apply. + */ + async applyTemporaryBridges(bridges) { + this.#checkIfInitialized(); + + if ( + bridges.source !== TorBridgeSource.BuiltIn && + bridges.source !== TorBridgeSource.BridgeDB + ) { + throw new Error(`Invalid bridge source ${bridges.source}`); + } + + const bridgeSettings = { + enabled: true, + source: bridges.source, + builtin_type: String(bridges.builtin_type), + bridge_strings: structuredClone(bridges.bridge_strings), + }; + + this.#fixupBridgeSettings(bridgeSettings); + + // After checks are complete, we commit them. + this.#temporaryBridgeSettings = bridgeSettings; + + await this.#applySettings({ bridges: true }, { useTemporaryBridges: true }); + } + + /** + * Save to current temporary bridges to be permanent instead. + */ + async saveTemporaryBridges() { + this.#checkIfInitialized(); + if (!this.#temporaryBridgeSettings) { + lazy.logger.warn("No temporary bridges to save"); + return; + } + const bridgeSettings = this.#temporaryBridgeSettings; + this.#temporaryBridgeSettings = null; + await this.changeSettings({ bridges: bridgeSettings }); + } + + /** + * Clear the current temporary bridges. + */ + async clearTemporaryBridges() { + this.#checkIfInitialized(); + if (!this.#temporaryBridgeSettings) { + lazy.logger.debug("No temporary bridges to clear"); + return; + } + this.#temporaryBridgeSettings = null; + await this.#applySettings({ bridges: true }); + } +} + +export const TorSettings = new TorSettingsImpl(); diff --git a/toolkit/modules/moz.build b/toolkit/modules/moz.build @@ -155,6 +155,7 @@ EXTRA_JS_MODULES += [ "AsyncPrefs.sys.mjs", "Bech32Decode.sys.mjs", "BinarySearch.sys.mjs", + "BridgeDB.sys.mjs", "BrowserTelemetryUtils.sys.mjs", "BrowserUtils.sys.mjs", "CanonicalJSON.sys.mjs", @@ -164,6 +165,7 @@ EXTRA_JS_MODULES += [ "ContentDOMReference.sys.mjs", "CreditCard.sys.mjs", "DeferredTask.sys.mjs", + "DomainFrontedRequests.sys.mjs", "E10SUtils.sys.mjs", "EventEmitter.sys.mjs", "FileUtils.sys.mjs", @@ -188,6 +190,7 @@ EXTRA_JS_MODULES += [ "LayoutUtils.sys.mjs", "Log.sys.mjs", "LogManager.sys.mjs", + "Moat.sys.mjs", "NewTabUtils.sys.mjs", "NLP.sys.mjs", "ObjectUtils.sys.mjs", @@ -209,6 +212,8 @@ EXTRA_JS_MODULES += [ "Sqlite.sys.mjs", "SubDialog.sys.mjs", "Timer.sys.mjs", + "TorConnect.sys.mjs", + "TorSettings.sys.mjs", "TorStrings.sys.mjs", "Troubleshoot.sys.mjs", "UpdateUtils.sys.mjs",