richard pushed to branch tor-browser-115.1.0esr-13.0-1 at The Tor Project / Applications / Tor Browser
Commits: 66229717 by Pier Angelo Vendrame at 2023-07-27T18:11:55+02:00 fixup! Bug 40933: Add tor-launcher functionality
Bug 41844: Added a couple of wrappers for Onion Auth on TorProtocolService.
- - - - - 2cde9fc3 by Pier Angelo Vendrame at 2023-07-27T18:11:56+02:00 fixup! Bug 30237: Add v3 onion services client authentication prompt
Bug 41844: Stop using the control port directly
- - - - - b2cd0ee8 by Pier Angelo Vendrame at 2023-07-27T18:11:57+02:00 fixup! Bug 40933: Add tor-launcher functionality
Small improvements on event registration.
- - - - - 26152fa9 by Pier Angelo Vendrame at 2023-07-27T18:11:57+02:00 fixup! Bug 40933: Add tor-launcher functionality
Bug 41844: Do not use a the control port directly.
Collect the bridge node for the about:preferences#connection page in TorMonitorService.
Also, move parts of the circuit display to TorMonitorService and TorProtocolService.
- - - - - 749aeaca by Pier Angelo Vendrame at 2023-07-27T18:11:58+02:00 fixup! Bug 31286: Implementation of bridge, proxy, and firewall settings in about:preferences#connection
Bug 41844: Do not use the control port directly
Do not use the controller in the settings frontend. Instead, let TorMonitorService collect the first node's fingerprint.
- - - - - 20641450 by Pier Angelo Vendrame at 2023-07-27T18:11:58+02:00 fixup! Bug 3455: Add DomainIsolator, for isolating circuit by domain.
Bug 41844: Do not use the control port directly.
Use TorDomainIsolator also as a backend for the circuit display.
- - - - - 1a8be7b1 by Pier Angelo Vendrame at 2023-07-27T18:11:59+02:00 fixup! Bug 41600: Add a tor circuit display panel.
Bug 41844: Have a separate backend for the tor circuits
Remove the backend stuff from the circuit display.
- - - - -
11 changed files:
- browser/base/content/browser.js - browser/components/onionservices/content/authPrompt.js - browser/components/onionservices/content/savedKeysDialog.js - browser/components/torcircuit/content/torCircuitPanel.js - browser/components/torpreferences/content/connectionPane.js - toolkit/components/tor-launcher/TorDomainIsolator.jsm → toolkit/components/tor-launcher/TorDomainIsolator.sys.mjs - toolkit/components/tor-launcher/TorMonitorService.sys.mjs - toolkit/components/tor-launcher/TorParsers.sys.mjs - toolkit/components/tor-launcher/TorProtocolService.sys.mjs - toolkit/components/tor-launcher/TorStartupService.sys.mjs - toolkit/components/tor-launcher/moz.build
Changes:
===================================== browser/base/content/browser.js ===================================== @@ -66,6 +66,7 @@ ChromeUtils.defineESModuleGetters(this, { TabsSetupFlowManager: "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs", TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TorDomainIsolator: "resource://gre/modules/TorDomainIsolator.sys.mjs", TranslationsParent: "resource://gre/actors/TranslationsParent.sys.mjs", UITour: "resource:///modules/UITour.sys.mjs", UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs", @@ -100,7 +101,6 @@ XPCOMUtils.defineLazyModuleGetters(this, { TorConnect: "resource:///modules/TorConnect.jsm", TorConnectState: "resource:///modules/TorConnect.jsm", TorConnectTopics: "resource:///modules/TorConnect.jsm", - TorDomainIsolator: "resource://gre/modules/TorDomainIsolator.jsm", Translation: "resource:///modules/translation/TranslationParent.jsm", webrtcUI: "resource:///modules/webrtcUI.jsm", ZoomUI: "resource:///modules/ZoomUI.jsm",
===================================== browser/components/onionservices/content/authPrompt.js ===================================== @@ -7,6 +7,7 @@ XPCOMUtils.defineLazyModuleGetters(this, { OnionAuthUtil: "chrome://browser/content/onionservices/authUtil.jsm", CommonUtils: "resource://services-common/utils.js", + TorProtocolService: "resource://gre/modules/TorProtocolService.jsm", TorStrings: "resource:///modules/TorStrings.jsm", });
@@ -192,10 +193,6 @@ const OnionAuthPrompt = (function () { let controllerFailureMsg = TorStrings.onionServices.authPrompt.failedToSetKey; try { - let { controller } = ChromeUtils.import( - "resource://torbutton/modules/tor-control-port.js" - ); - let torController = await controller(); // ^(subdomain.)*onionserviceid.onion$ (case-insensitive) const onionServiceIdRegExp = /^(.*.)*(?<onionServiceId>[a-z2-7]{56}).onion$/i; @@ -206,8 +203,7 @@ const OnionAuthPrompt = (function () {
let checkboxElem = this._getCheckboxElement(); let isPermanent = checkboxElem && checkboxElem.checked; - torController - .onionAuthAdd(onionServiceId, base64key, isPermanent) + TorProtocolService.onionAuthAdd(onionServiceId, base64key, isPermanent) .then(aResponse => { // Success! Reload the page. this._browser.sendMessageToActor(
===================================== browser/components/onionservices/content/savedKeysDialog.js ===================================== @@ -10,8 +10,8 @@ ChromeUtils.defineModuleGetter(
ChromeUtils.defineModuleGetter( this, - "controller", - "resource://torbutton/modules/tor-control-port.js" + "TorProtocolService", + "resource://gre/modules/TorProtocolService.jsm" );
var gOnionServicesSavedKeysDialog = { @@ -49,11 +49,9 @@ var gOnionServicesSavedKeysDialog = { const controllerFailureMsg = TorStrings.onionServices.authPreferences.failedToRemoveKey; try { - const torController = await controller(); - // Remove in reverse index order to avoid issues caused by index changes. for (let i = indexesToDelete.length - 1; i >= 0; --i) { - await this._deleteOneKey(torController, indexesToDelete[i]); + await this._deleteOneKey(indexesToDelete[i]); } } catch (e) { if (e.torMessage) { @@ -127,8 +125,7 @@ var gOnionServicesSavedKeysDialog = { try { this._tree.view = this;
- const torController = await controller(); - const keyInfoList = await torController.onionAuthViewKeys(); + const keyInfoList = await TorProtocolService.onionAuthViewKeys(); if (keyInfoList) { // Filter out temporary keys. this._keyInfoList = keyInfoList.filter(aKeyInfo => { @@ -165,9 +162,9 @@ var gOnionServicesSavedKeysDialog = { },
// This method may throw; callers should catch errors. - async _deleteOneKey(aTorController, aIndex) { + async _deleteOneKey(aIndex) { const keyInfoObj = this._keyInfoList[aIndex]; - await aTorController.onionAuthRemove(keyInfoObj.hsAddress); + await TorProtocolService.onionAuthRemove(keyInfoObj.hsAddress); this._tree.view.selection.clearRange(aIndex, aIndex); this._keyInfoList.splice(aIndex, 1); this._tree.rowCountChanged(aIndex + 1, -1);
===================================== browser/components/torcircuit/content/torCircuitPanel.js ===================================== @@ -1,18 +1,5 @@ /* eslint-env mozilla/browser-window */
-/** - * Stores the data associated with a circuit node. - * - * @typedef NodeData - * @property {string[]} ipAddrs - The ip addresses associated with this node. - * @property {string?} bridgeType - The bridge type for this node, or "" if the - * node is a bridge but the type is unknown, or null if this is not a bridge - * node. - * @property {string?} regionCode - An upper case 2-letter ISO3166-1 code for - * the first ip address, or null if there is no region. This should also be a - * valid BCP47 Region subtag. - */ - /** * Data about the current domain and circuit for a xul:browser. * @@ -35,29 +22,6 @@ var gTorCircuitPanel = { * @type {Element} */ toolbarButton: null, - /** - * A list of IDs for "mature" circuits (those that have conveyed a stream). - * - * @type {string[]} - */ - _knownCircuitIDs: [], - /** - * Stores the circuit nodes for each SOCKS username/password pair. The keys - * are of the form "<username>|<password>". - * - * @type {Map<string, NodeData[]>} - */ - _credentialsToCircuitNodes: new Map(), - /** - * Browser data for their currently shown page. - * - * This data may be stale for a given browser since we only update this data - * when loading a new page in the currently selected browser, when switching - * tabs, or if we find a new circuit for the current browser. - * - * @type {WeakMap<MozBrowser, BrowserCircuitData>} - */ - _browserData: new WeakMap(), /** * The data for the currently shown browser. * @@ -71,6 +35,13 @@ var gTorCircuitPanel = { */ _isActive: false,
+ /** + * The topic on which circuit changes are broadcast. + * + * @type {string} + */ + TOR_CIRCUIT_TOPIC: "TorCircuitChange", + /** * Initialize the panel. */ @@ -86,31 +57,6 @@ var gTorCircuitPanel = { maxLogLevelPref: "browser.torcircuitpanel.loglevel", });
- const { wait_for_controller } = ChromeUtils.import( - "resource://torbutton/modules/tor-control-port.js" - ); - wait_for_controller().then( - controller => { - if (!this._isActive) { - // uninit() was called before resolution. - return; - } - // FIXME: We should be using some dedicated integrated back end to - // store circuit information, rather than collecting it all here in the - // front end. See tor-browser#41700. - controller.watchEvent( - "STREAM", - streamEvent => streamEvent.StreamStatus === "SENTCONNECT", - streamEvent => this._collectCircuit(controller, streamEvent) - ); - }, - error => { - this._log.error( - `Not collecting circuits because of an error: ${error.message}` - ); - } - ); - this.panel = document.getElementById("tor-circuit-panel"); this._panelElements = { heading: document.getElementById("tor-circuit-heading"), @@ -245,6 +191,9 @@ var gTorCircuitPanel = { // Notified of new locations for the currently selected browser (tab) *and* // switching selected browser. gBrowser.addProgressListener(this._locationListener); + + // Get notifications for circuit changes. + Services.obs.addObserver(this, this.TOR_CIRCUIT_TOPIC); },
/** @@ -253,6 +202,17 @@ var gTorCircuitPanel = { uninit() { this._isActive = false; gBrowser.removeProgressListener(this._locationListener); + Services.obs.removeObserver(this, this.TOR_CIRCUIT_TOPIC); + }, + + /** + * Observe circuit changes. + */ + observe(subject, topic, data) { + if (topic === this.TOR_CIRCUIT_TOPIC) { + // TODO: Maybe check if we actually need to do something earlier. + this._updateCurrentBrowser(); + } },
/** @@ -286,109 +246,6 @@ var gTorCircuitPanel = { window.openWebLinkIn(this._panelElements.aliasLink.href, where); },
- /** - * Collect circuit data for the found circuits, to be used later for display. - * - * @param {controller} controller - The tor controller. - * @param {object} streamEvent - The streamEvent for the new circuit. - */ - async _collectCircuit(controller, streamEvent) { - const id = streamEvent.CircuitID; - if (this._knownCircuitIDs.includes(id)) { - return; - } - this._log.debug(`New streamEvent.CircuitID: ${id}.`); - // FIXME: This list grows and is never freed. See tor-browser#41700. - this._knownCircuitIDs.push(id); - const circuitStatus = (await controller.getInfo("circuit-status"))?.find( - circuit => circuit.id === id - ); - if (!circuitStatus?.SOCKS_USERNAME || !circuitStatus?.SOCKS_PASSWORD) { - return; - } - const nodes = await Promise.all( - circuitStatus.circuit.map(names => - this._nodeDataForCircuit(controller, names) - ) - ); - // Remove quotes from the strings. - const username = circuitStatus.SOCKS_USERNAME.replace(/^"(.*)"$/, "$1"); - const password = circuitStatus.SOCKS_PASSWORD.replace(/^"(.*)"$/, "$1"); - const credentials = `${username}|${password}`; - // FIXME: This map grows and is never freed. We cannot simply request this - // information when needed because it is no longer available once the - // circuit is dropped, even if the web page is still displayed. - // See tor-browser#41700. - this._credentialsToCircuitNodes.set(credentials, nodes); - // Update the circuit in case the current page gains a new circuit whilst - // the popup is still open. - this._updateCurrentBrowser(credentials); - }, - - /** - * Fetch the node data for the given circuit node. - * - * @param {controller} controller - The tor controller. - * @param {string[]} circuitNodeNames - The names for the circuit node. Only - * the first name, the node id, will be used. - * - * @returns {NodeData} - The data for this circuit node. - */ - async _nodeDataForCircuit(controller, circuitNodeNames) { - // The first "name" in circuitNodeNames is the id. - // Remove the leading '$' if present. - const id = circuitNodeNames[0].replace(/^$/, ""); - let result = { ipAddrs: [], bridgeType: null, regionCode: null }; - const bridge = (await controller.getConf("bridge"))?.find( - foundBridge => foundBridge.ID?.toUpperCase() === id.toUpperCase() - ); - const addrRe = /^[?([^]]+)]?:\d+$/; - if (bridge) { - result.bridgeType = bridge.type ?? ""; - // Attempt to get an IP address from bridge address string. - const ip = bridge.address.match(addrRe)?.[1]; - if (ip && !ip.startsWith("0.")) { - result.ipAddrs.push(ip); - } - } else { - // Either dealing with a relay, or a bridge whose fingerprint is not saved - // in torrc. - let statusMap; - try { - statusMap = await controller.getInfo("ns/id/" + id); - } catch { - // getInfo will throw if the given id is not a relay. - // This probably means we are dealing with a user-provided bridge with - // no fingerprint. - // We don't know the ip/ipv6 or type, so leave blank. - result.bridgeType = ""; - return result; - } - if (statusMap.IP && !statusMap.IP.startsWith("0.")) { - result.ipAddrs.push(statusMap.IP); - } - const ip6 = statusMap.IPv6?.match(addrRe)?.[1]; - if (ip6) { - result.ipAddrs.push(ip6); - } - } - if (result.ipAddrs.length) { - // Get the country code for the node's IP address. - let regionCode; - try { - // Expect a 2-letter ISO3166-1 code, which should also be a valid BCP47 - // Region subtag. - regionCode = await controller.getInfo( - "ip-to-country/" + result.ipAddrs[0] - ); - } catch {} - if (regionCode && regionCode !== "??") { - result.regionCode = regionCode.toUpperCase(); - } - } - return result; - }, - /** * A list of schemes to never show the circuit display for. * @@ -398,71 +255,50 @@ var gTorCircuitPanel = { * * @type {string[]} */ - // FIXME: Have a back end that handles this instead. See tor-browser#41700. + // FIXME: Check if we find a UX to handle some of these cases, and if we + // manage to solve some technical issues. + // See tor-browser#41700 and tor-browser!699. _ignoredSchemes: ["about", "file", "chrome", "resource"],
/** * Update the current circuit and domain data for the currently selected * browser, possibly changing the UI. - * - * @param {string?} [matchingCredentials=null] - If given, only update the - * current browser data if the current browser's credentials match. */ - _updateCurrentBrowser(matchingCredentials = null) { + _updateCurrentBrowser() { const browser = gBrowser.selectedBrowser; const domain = TorDomainIsolator.getDomainForBrowser(browser); + const nodes = TorDomainIsolator.getCircuit( + browser, + domain, + browser.contentPrincipal.originAttributes.userContextId + ); // We choose the currentURI, which matches what is shown in the URL bar and // will match up with the domain. // In contrast, documentURI corresponds to the shown page. E.g. it could // point to "about:certerror". const scheme = browser.currentURI?.scheme;
- let credentials = TorDomainIsolator.getSocksProxyCredentials( - domain, - browser.contentPrincipal.originAttributes.userContextId - ); - if (credentials) { - credentials = `${credentials.username}|${credentials.password}`; - } - - if (matchingCredentials && matchingCredentials !== credentials) { - // This update was triggered by the circuit update for some other browser - // or process. - return; - } - - let nodes = this._credentialsToCircuitNodes.get(credentials) ?? []; - - const prevData = this._browserData.get(browser); - if ( - prevData && - prevData.domain && - prevData.domain === domain && - prevData.scheme === scheme && - prevData.nodes.length && - !nodes.length - ) { - // Since this is the same domain, for the same browser, and we used to - // have circuit nodes, we *assume* we are re-generating a circuit. So we - // keep the old circuit data around for the time being. - // FIXME: Have a back end that makes this explicit, rather than an - // assumption. See tor-browser#41700. - nodes = prevData.nodes; - this._log.debug(`Keeping old circuit for ${domain}.`); - } - - this._browserData.set(browser, { domain, scheme, nodes }); if ( this._currentBrowserData && this._currentBrowserData.domain === domain && this._currentBrowserData.scheme === scheme && - this._currentBrowserData.nodes === nodes + this._currentBrowserData.nodes.length === nodes.length && + // If non-null, the fingerprints of the nodes match. + (!nodes || + nodes.every( + (n, index) => + n.fingerprint === this._currentBrowserData.nodes[index].fingerprint + )) ) { // No change. + this._log.debug( + "Skipping browser update because the data is already up to date." + ); return; }
- this._currentBrowserData = this._browserData.get(browser); + this._currentBrowserData = { domain, scheme, nodes }; + this._log.debug("Updating current browser.", this._currentBrowserData);
if ( // Schemes where we always want to hide the display.
===================================== browser/components/torpreferences/content/connectionPane.js ===================================== @@ -17,6 +17,9 @@ const { TorSettings, TorSettingsTopics, TorSettingsData, TorBridgeSource } = const { TorProtocolService } = ChromeUtils.import( "resource://gre/modules/TorProtocolService.jsm" ); +const { TorMonitorService, TorMonitorTopics } = ChromeUtils.import( + "resource://gre/modules/TorMonitorService.jsm" +);
const { TorConnect, TorConnectTopics, TorConnectState, TorCensorshipLevel } = ChromeUtils.import("resource:///modules/TorConnect.jsm"); @@ -144,8 +147,6 @@ const gConnectionPane = (function () {
_internetStatus: InternetStatus.Unknown,
- _controller: null, - _currentBridgeId: null,
// populate xul with strings and cache the relevant elements @@ -727,9 +728,10 @@ const gConnectionPane = (function () { }; // Use a promise to avoid blocking the population of the page // FIXME: Stop using a JSON file, and switch to properties - fetch( + const annotationPromise = fetch( "chrome://browser/content/torpreferences/bridgemoji/annotations.json" - ).then(async res => { + ); + annotationPromise.then(async res => { const annotations = await res.json(); const bcp47 = Services.locale.appLocaleAsBCP47; const dash = bcp47.indexOf("-"); @@ -749,6 +751,7 @@ const gConnectionPane = (function () { ".currently-connected" )) { card.classList.remove("currently-connected"); + card.querySelector(selectors.bridges.cardQrGrid).style.height = ""; } if (!this._currentBridgeId) { return; @@ -769,72 +772,17 @@ const gConnectionPane = (function () { placeholder.replaceWith(...cards); this._checkBridgeCardsHeight(); }; - try { - const { controller } = ChromeUtils.import( - "resource://torbutton/modules/tor-control-port.js" - ); - // Avoid the cache because we set our custom event watcher, and at the - // moment, watchers cannot be removed from a controller. - controller(true).then(aController => { - this._controller = aController; - // Getting the circuits may be enough, if we have bootstrapped for a - // while, but at the beginning it gives many bridges as connected, - // because tor pokes all the bridges to find the best one. - // Also, watching circuit events does not work, at the moment, but in - // any case, checking the stream has the advantage that we can see if - // it really used for a connection, rather than tor having created - // this circuit to check if the bridge can be used. We do this by - // checking if the stream has SOCKS username, which actually contains - // the destination of the stream. - // FIXME: We only know the currentBridge *after* a circuit event, but - // if the circuit event is sent *before* about:torpreferences is - // opened we will miss it. Therefore this approach only works if a - // circuit is created after opening about:torconnect. A dedicated - // backend outside of about:preferences would help, and could be - // shared with gTorCircuitPanel. See tor-browser#41700. - this._controller.watchEvent( - "STREAM", - event => - event.StreamStatus === "SUCCEEDED" && "SOCKS_USERNAME" in event, - async event => { - const circuitStatuses = await this._controller.getInfo( - "circuit-status" - ); - if (!circuitStatuses) { - return; - } - for (const status of circuitStatuses) { - if (status.id === event.CircuitID && status.circuit.length) { - // The id in the circuit begins with a $ sign. - const id = status.circuit[0][0].replace(/^$/, ""); - if (id !== this._currentBridgeId) { - const bridge = ( - await this._controller.getConf("bridge") - )?.find( - foundBridge => - foundBridge.ID?.toUpperCase() === id.toUpperCase() - ); - if (!bridge) { - // Either there is no bridge, or bridge with no - // fingerprint. - this._currentBridgeId = null; - } else { - this._currentBridgeId = id; - } - this._updateConnectedBridges(); - } - break; - } - } - } - ); - }); - } catch (err) { - console.warn( - "We could not load torbutton, bridge statuses will not be updated", - err - ); - } + this._checkConnectedBridge = () => { + // TODO: We could make sure TorSettings is in sync by monitoring also + // changes of settings. At that point, we could query it, instead of + // doing a query over the control port. + const bridge = TorMonitorService.currentBridge; + if (bridge?.fingerprint !== this._currentBridgeId) { + this._currentBridgeId = bridge?.fingerprint ?? null; + this._updateConnectedBridges(); + } + }; + annotationPromise.then(this._checkConnectedBridge.bind(this));
// Add a new bridge prefpane.querySelector(selectors.bridges.addHeader).textContent = @@ -927,6 +875,7 @@ const gConnectionPane = (function () { });
Services.obs.addObserver(this, TorConnectTopics.StateChange); + Services.obs.addObserver(this, TorMonitorTopics.BridgeChanged); },
init() { @@ -950,11 +899,7 @@ const gConnectionPane = (function () { // unregister our observer topics Services.obs.removeObserver(this, TorSettingsTopics.SettingChanged); Services.obs.removeObserver(this, TorConnectTopics.StateChange); - - if (this._controller !== null) { - this._controller.close(); - this._controller = null; - } + Services.obs.removeObserver(this, TorMonitorTopics.BridgeChanged); },
// whether the page should be present in about:preferences @@ -985,6 +930,12 @@ const gConnectionPane = (function () { this.onStateChange(); break; } + case TorMonitorTopics.BridgeChanged: { + if (data?.fingerprint !== this._currentBridgeId) { + this._checkConnectedBridge(); + } + break; + } } },
@@ -1028,7 +979,7 @@ const gConnectionPane = (function () { onRemoveAllBridges() { TorSettings.bridges.enabled = false; TorSettings.bridges.bridge_strings = ""; - if (TorSettings.bridges.source == TorBridgeSource.BuiltIn) { + if (TorSettings.bridges.source === TorBridgeSource.BuiltIn) { TorSettings.bridges.builtin_type = ""; } TorSettings.saveToPrefs();
===================================== toolkit/components/tor-launcher/TorDomainIsolator.jsm → toolkit/components/tor-launcher/TorDomainIsolator.sys.mjs ===================================== @@ -1,13 +1,14 @@ -// A component for Tor Browser that puts requests from different -// first party domains on separate Tor circuits. - -var EXPORTED_SYMBOLS = ["TorDomainIsolator"]; +/** + * A component for Tor Browser that puts requests from different first party + * domains on separate Tor circuits. + */
-const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); -const { XPCOMUtils } = ChromeUtils.import( - "resource://gre/modules/XPCOMUtils.jsm" -); -const { ConsoleAPI } = ChromeUtils.import("resource://gre/modules/Console.jsm"); +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { ConsoleAPI } from "resource://gre/modules/Console.sys.mjs"; +import { + clearInterval, + setInterval, +} from "resource://gre/modules/Timer.sys.mjs";
const lazy = {};
@@ -18,11 +19,10 @@ XPCOMUtils.defineLazyServiceGetters(lazy, { ], });
-ChromeUtils.defineModuleGetter( - lazy, - "TorProtocolService", - "resource://gre/modules/TorProtocolService.jsm" -); +ChromeUtils.defineESModuleGetters(lazy, { + TorMonitorTopics: "resource://gre/modules/TorMonitorService.sys.mjs", + TorProtocolService: "resource://gre/modules/TorProtocolService.sys.mjs", +});
const logger = new ConsoleAPI({ prefix: "TorDomainIsolator", @@ -33,6 +33,12 @@ const logger = new ConsoleAPI({ // The string to use instead of the domain when it is not known. const CATCHALL_DOMAIN = "--unknown--";
+// The maximum lifetime for the catch-all circuit in milliseconds. +// When the catch-all circuit is needed, we check if more than this amount of +// time has passed since we last changed it nonce, and in case we change it +// again. +const CATCHALL_MAX_LIFETIME = 600_000; + // The preference to observe, to know whether isolation should be enabled or // disabled. const NON_TOR_PROXY_PREF = "extensions.torbutton.use_nontor_proxy"; @@ -40,23 +46,92 @@ const NON_TOR_PROXY_PREF = "extensions.torbutton.use_nontor_proxy"; // The topic of new identity, to observe to cleanup all the nonces. const NEW_IDENTITY_TOPIC = "new-identity-requested";
+// The topic on which we broacast circuit change notifications. +const TOR_CIRCUIT_TOPIC = "TorCircuitChange"; + +// We have an interval to delete circuits that are not reclaimed by any browser. +const CLEAR_TIMEOUT = 600_000; + +/** + * @typedef {string} CircuitId A string that we use to identify a circuit. + * Currently, it is a string that combines SOCKS credentials, to make it easier + * to use as a map key. + * It is not related to Tor's CircuitIDs. + */ +/** + * @typedef {number} BrowserId + */ +/** + * @typedef {NodeData[]} CircuitData The data about the nodes, ordered from + * guard (or bridge) to exit. + */ +/** + * @typedef BrowserCircuits Circuits related to a certain combination of + * isolators (first-party domain and user context ID, currently). + * @property {CircuitId} current The id of the last known circuit that has been + * used to fetch data for the isolated context. + * @property {CircuitId?} pending The id of the last used circuit for this + * isolation context. We might or might not know data about it, yet. But if we + * know it, we should move this id into current. + */ + class TorDomainIsolatorImpl { - // A mutable map that records what nonce we are using for each domain. + /** + * A mutable map that records what nonce we are using for each domain. + * + * @type {Map<string, string>} + */ #noncesForDomains = new Map();
- // A mutable map that records what nonce we are using for each tab container. + /** + * A mutable map that records what nonce we are using for each tab container. + * + * @type {Map<string, string>} + */ #noncesForUserContextId = new Map();
- // A bool that controls if we use SOCKS auth for isolation or not. + /** + * Tell whether we use SOCKS auth for isolation or not. + * + * @type {boolean} + */ #isolationEnabled = true;
- // Specifies when the current catch-all circuit was first used + /** + * Specifies when the current catch-all circuit was first used. + * + * @type {integer} + */ #catchallDirtySince = Date.now();
+ /** + * A map that associates circuit ids to the circuit information. + * + * @type {Map<CircuitId, CircuitData>} + */ + #knownCircuits = new Map(); + + /** + * A map that associates a certain browser to all the circuits it used or it + * is going to use. + * The circuits are keyed on the SOCKS username, which we take for granted + * being a combination of the first-party domain and the user context id. + * + * @type {Map<BrowserId, Map<string, BrowserCircuits>>} + */ + #browsers = new Map(); + + /** + * The handle of the interval we use to cleanup old circuit data. + * + * @type {number?} + */ + #cleanupIntervalId = null; + /** * Initialize the domain isolator. - * This function will setup the proxy filter that injects the credentials and - * register some observers. + * This function will setup the proxy filter that injects the credentials, + * register some observers, and setup the cleaning interval. */ init() { logger.info("Setup circuit isolation by domain and user context"); @@ -68,14 +143,25 @@ class TorDomainIsolatorImpl {
Services.prefs.addObserver(NON_TOR_PROXY_PREF, this); Services.obs.addObserver(this, NEW_IDENTITY_TOPIC); + Services.obs.addObserver(this, lazy.TorMonitorTopics.StreamSucceeded); + + this.#cleanupIntervalId = setInterval( + this.#clearKnownCircuits.bind(this), + CLEAR_TIMEOUT + ); }
/** - * Removes the observers added in the initialization. + * Removes the observers added in the initialization and stops the cleaning + * interval. */ uninit() { Services.prefs.removeObserver(NON_TOR_PROXY_PREF, this); Services.obs.removeObserver(this, NEW_IDENTITY_TOPIC); + Services.obs.removeObserver(this, lazy.TorMonitorTopics.StreamSucceeded); + clearInterval(this.#cleanupIntervalId); + this.#cleanupIntervalId = null; + this.clearIsolation(); }
enable() { @@ -89,52 +175,52 @@ class TorDomainIsolatorImpl { }
/** - * Return the credentials to use as username and password for the SOCKS proxy, - * given a certain domain and userContextId. Optionally, create them. + * Get the last circuit used in a certain browser. + * The returned data is created when the circuit is first seen, therefore it + * could be stale (i.e., the circuit might not be available anymore). * - * @param {string} firstPartyDomain The first party domain associated to the requests - * @param {string} userContextId The context ID associated to the request - * @param {bool} create Whether to create the nonce, if it is not available - * @returns {object|null} Either the credential, or null if we do not have them and create is - * false. + * @param {MozBrowser} browser The browser to get data for + * @param {string} domain The first party domain we want to get the circuit + * for + * @param {number} userContextId The user context domain we want to get the + * circuit for + * @returns {NodeData[]} The node data, or an empty array if we do not have + * data for the requested key. */ - getSocksProxyCredentials(firstPartyDomain, userContextId, create = false) { - if (!this.#noncesForDomains.has(firstPartyDomain)) { - if (!create) { - return null; - } - const nonce = this.#nonce(); - logger.info(`New nonce for first party ${firstPartyDomain}: ${nonce}`); - this.#noncesForDomains.set(firstPartyDomain, nonce); + getCircuit(browser, domain, userContextId) { + const username = this.#makeUsername(domain, userContextId); + const circuits = this.#browsers.get(browser.browserId)?.get(username); + // This is the only place where circuit data can go out, so the only place + // where it makes a difference to check whether the pending circuit is still + // pending, or it has actually got data. + const pending = this.#knownCircuits.get(circuits?.pending); + if (pending?.length) { + circuits.current = circuits.pending; + circuits.pending = null; + return pending; } - if (!this.#noncesForUserContextId.has(userContextId)) { - if (!create) { - return null; - } - const nonce = this.#nonce(); - logger.info(`New nonce for userContextId ${userContextId}: ${nonce}`); - this.#noncesForUserContextId.set(userContextId, nonce); - } - return { - username: this.#makeUsername(firstPartyDomain, userContextId), - password: - this.#noncesForDomains.get(firstPartyDomain) + - this.#noncesForUserContextId.get(userContextId), - }; + // TODO: At this point we already know if we expect a circuit change for + // this key: (circuit?.pending && !pending). However, we do not consume this + // data yet in the frontend, so do not send it for now. + return this.#knownCircuits.get(circuits?.current) ?? []; }
/** * Create a new nonce for the FP domain of the selected browser and reload the * tab with a new circuit. * - * @param {object} browser Should be the gBrowser from the context of the - * caller + * @param {object} globalBrowser Should be the gBrowser from the context of + * the caller */ - newCircuitForBrowser(browser) { - const firstPartyDomain = getDomainForBrowser(browser.selectedBrowser); + newCircuitForBrowser(globalBrowser) { + const browser = globalBrowser.selectedBrowser; + const firstPartyDomain = getDomainForBrowser(browser); this.#newCircuitForDomain(firstPartyDomain); - // TODO: How to properly handle the user context? Should we use - // (domain, userContextId) pairs, instead of concatenating nonces? + const { username, password } = this.#getSocksProxyCredentials( + firstPartyDomain, + browser.contentPrincipal.originAttributes.userContextId + ); + this.#trackBrowser(browser, username, password); browser.reloadWithFlags(Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE); }
@@ -147,12 +233,15 @@ class TorDomainIsolatorImpl {
// Per-domain and per contextId nonces are stored in maps, so simply clear // them. + // Notice that the catch-all circuit is included in #noncesForDomains, so we + // are implicilty cleaning it. Should this change, we should change its + // nonce explicitly here. this.#noncesForDomains.clear(); this.#noncesForUserContextId.clear(); + this.#catchallDirtySince = Date.now();
- // Force a rotation on the next catch-all circuit use by setting the - // creation time to the epoch. - this.#catchallDirtySince = 0; + this.#knownCircuits.clear(); + this.#browsers.clear(); }
async observe(subject, topic, data) { @@ -173,55 +262,20 @@ class TorDomainIsolatorImpl { logger.error("Could not send the newnym command", e); // TODO: What UX to use here? See tor-browser#41708 } + } else if (topic === lazy.TorMonitorTopics.StreamSucceeded) { + const { username, password, circuit } = subject.wrappedJSObject; + this.#updateCircuit(username, password, circuit); } }
/** - * Setup a filter that for every HTTPChannel, replaces the default SOCKS proxy - * with one that authenticates to the SOCKS server (the tor client process) - * with a username (the first party domain and userContextId) and a nonce - * password. - * Tor provides a separate circuit for each username+password combination. + * Setup a filter that for every HTTPChannel. */ #setupProxyFilter() { - const filterFunction = (aChannel, aProxy) => { - if (!this.#isolationEnabled) { - return aProxy; - } - try { - const channel = aChannel.QueryInterface(Ci.nsIChannel); - let firstPartyDomain = - channel.loadInfo.originAttributes.firstPartyDomain; - const userContextId = channel.loadInfo.originAttributes.userContextId; - if (firstPartyDomain === "") { - firstPartyDomain = CATCHALL_DOMAIN; - if (Date.now() - this.#catchallDirtySince > 1000 * 10 * 60) { - logger.info( - "tor catchall circuit has been dirty for over 10 minutes. Rotating." - ); - this.#newCircuitForDomain(CATCHALL_DOMAIN); - this.#catchallDirtySince = Date.now(); - } - } - const replacementProxy = this.#applySocksProxyCredentials( - aProxy, - firstPartyDomain, - userContextId - ); - logger.debug( - `Requested ${channel.URI.spec} via ${replacementProxy.username}:${replacementProxy.password}` - ); - return replacementProxy; - } catch (e) { - logger.error("Error while setting a new proxy", e); - return null; - } - }; - lazy.ProtocolProxyService.registerChannelFilter( { - applyFilter(aChannel, aProxy, aCallback) { - aCallback.onProxyFilterResult(filterFunction(aChannel, aProxy)); + applyFilter: (aChannel, aProxy, aCallback) => { + aCallback.onProxyFilterResult(this.#proxyFilter(aChannel, aProxy)); }, }, 0 @@ -229,33 +283,96 @@ class TorDomainIsolatorImpl { }
/** - * Takes a proxyInfo object (originalProxy) and returns a new proxyInfo - * object with the same properties, except the username is set to the - * the domain and userContextId, and the password is a nonce. + * Replaces the default SOCKS proxy with one that authenticates to the SOCKS + * server (the tor client process) with a username (the first party domain and + * userContextId) and a nonce password. + * Tor provides a separate circuit for each username+password combination. + * + * @param {nsIChannel} aChannel The channel we are setting the proxy for + * @param {nsIProxyInfo} aProxy The original proxy + * @returns {nsIProxyInfo} The new proxy to use */ - #applySocksProxyCredentials(originalProxy, domain, userContextId) { - const proxy = originalProxy.QueryInterface(Ci.nsIProxyInfo); - const { username, password } = this.getSocksProxyCredentials( - domain, - userContextId, - true - ); - return lazy.ProtocolProxyService.newProxyInfoWithAuth( - "socks", - proxy.host, - proxy.port, - username, - password, - "", // aProxyAuthorizationHeader - "", // aConnectionIsolationKey - proxy.flags, - proxy.failoverTimeout, - proxy.failoverProxy - ); + #proxyFilter(aChannel, aProxy) { + if (!this.#isolationEnabled) { + return aProxy; + } + try { + const channel = aChannel.QueryInterface(Ci.nsIChannel); + let firstPartyDomain = channel.loadInfo.originAttributes.firstPartyDomain; + const userContextId = channel.loadInfo.originAttributes.userContextId; + if (!firstPartyDomain) { + firstPartyDomain = CATCHALL_DOMAIN; + if (Date.now() - this.#catchallDirtySince > CATCHALL_MAX_LIFETIME) { + logger.info( + "tor catchall circuit has reached its maximum lifetime. Rotating." + ); + this.#newCircuitForDomain(CATCHALL_DOMAIN); + } + } + const { username, password } = this.#getSocksProxyCredentials( + firstPartyDomain, + userContextId + ); + const browser = this.#getBrowserForChannel(channel); + if (browser) { + this.#trackBrowser(browser, username, password); + } + logger.debug(`Requested ${channel.URI.spec} via ${username}:${password}`); + const proxy = aProxy.QueryInterface(Ci.nsIProxyInfo); + return lazy.ProtocolProxyService.newProxyInfoWithAuth( + "socks", + proxy.host, + proxy.port, + username, + password, + "", // aProxyAuthorizationHeader + "", // aConnectionIsolationKey + proxy.flags, + proxy.failoverTimeout, + proxy.failoverProxy + ); + } catch (e) { + logger.error("Error while setting a new proxy", e); + return null; + } + } + + /** + * Return the credentials to use as username and password for the SOCKS proxy, + * given a certain domain and userContextId. + * A new random password will be created if not available yet. + * + * @param {string} firstPartyDomain The first party domain associated to the + * requests + * @param {number} userContextId The context ID associated to the request + * @returns {object} The credentials + */ + #getSocksProxyCredentials(firstPartyDomain, userContextId) { + if (!this.#noncesForDomains.has(firstPartyDomain)) { + const nonce = this.#nonce(); + logger.info(`New nonce for first party ${firstPartyDomain}: ${nonce}`); + this.#noncesForDomains.set(firstPartyDomain, nonce); + } + if (!this.#noncesForUserContextId.has(userContextId)) { + const nonce = this.#nonce(); + logger.info(`New nonce for userContextId ${userContextId}: ${nonce}`); + this.#noncesForUserContextId.set(userContextId, nonce); + } + // TODO: How to properly handle the user-context? Should we use + // (domain, userContextId) pairs, instead of concatenating nonces? + return { + username: this.#makeUsername(firstPartyDomain, userContextId), + password: + this.#noncesForDomains.get(firstPartyDomain) + + this.#noncesForUserContextId.get(userContextId), + }; }
/** * Combine the needed data into a username for the proxy. + * + * @param {string} domain The first-party domain associated to the request + * @param {integer} userContextId The userContextId associated to the request */ #makeUsername(domain, userContextId) { if (!domain) { @@ -264,12 +381,26 @@ class TorDomainIsolatorImpl { return `${domain}:${userContextId}`; }
+ /** + * Combine SOCKS username and password into a string to use as ID. + * + * @param {string} username The SOCKS username + * @param {string} password The SOCKS password + * @returns {CircuitId} A string that combines username and password and can + * be used for map lookups. + */ + #credentialsToId(username, password) { + return `${username}|${password}`; + } + /** * Generate a new 128 bit random tag. * * Strictly speaking both using a cryptographic entropy source and using 128 * bits of entropy for the tag are likely overkill, as correct behavior only * depends on how unlikely it is for there to be a collision. + * + * @returns {string} The random nonce */ #nonce() { return Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => @@ -279,12 +410,18 @@ class TorDomainIsolatorImpl {
/** * Re-generate the nonce for a certain domain. + * + * @param {string?} domain The first-party domain to re-create the nonce for. + * If empty or null, the catchall domain will be used. */ #newCircuitForDomain(domain) { if (!domain) { domain = CATCHALL_DOMAIN; } this.#noncesForDomains.set(domain, this.#nonce()); + if (domain === CATCHALL_DOMAIN) { + this.#catchallDirtySince = Date.now(); + } logger.info( `New domain isolation for ${domain}: ${this.#noncesForDomains.get( domain @@ -296,6 +433,8 @@ class TorDomainIsolatorImpl { * Re-generate the nonce for a userContextId. * * Currently, this function is not hooked to anything. + * + * @param {integer} userContextId The userContextId to re-create the nonce for */ #newCircuitForUserContextId(userContextId) { this.#noncesForUserContextId.set(userContextId, this.#nonce()); @@ -305,13 +444,182 @@ class TorDomainIsolatorImpl { )}` ); } + + /** + * Try to extract a browser from a channel. + * + * @param {nsIChannel} channel The channel to extract the browser from + * @returns {MozBrowser?} The browser the channel is associated to + */ + #getBrowserForChannel(channel) { + const browsers = + channel.loadInfo.browsingContext?.topChromeWindow?.gBrowser.browsers; + if (!browsers || !channel.loadInfo.browsingContext?.browserId) { + return null; + } + for (const browser of browsers) { + if (browser.browserId === channel.loadInfo.browsingContext.browserId) { + logger.debug( + "Matched browser with browserId", + channel.loadInfo.browsingContext.browserId + ); + return browser; + } + } + // Expected to arrive here for example for the update checker. + // If we find a way to check that, we could raise the level to a warn. + logger.debug("Browser not matched", channel); + return null; + } + + /** + * Associate the SOCKS credentials to a browser. + * If needed (the browser is associated for the first time, or it was already + * known but its credential changed), notify the related circuit display. + * + * @param {MozBrowser} browser The browser to track + * @param {string} username The SOCKS username + * @param {string} password The SOCKS password + */ + #trackBrowser(browser, username, password) { + let browserCircuits = this.#browsers.get(browser.browserId); + if (!browserCircuits) { + browserCircuits = new Map(); + this.#browsers.set(browser.browserId, browserCircuits); + } + const circuitIds = browserCircuits.get(username) ?? {}; + const id = this.#credentialsToId(username, password); + if (circuitIds.current === id) { + // The circuit with these credentials was already built (we already knew + // its nodes, or we would not have promoted it to the current circuit). + // We do not need to do anything else, because we cannot detect a change + // of nodes here. + return; + } + + logger.debug( + `Found new credentials ${username} ${password} for browser`, + browser + ); + const circuit = this.#knownCircuits.get(id); + if (circuit?.length) { + circuitIds.current = id; + if (circuitIds.pending === id) { + circuitIds.pending = null; + } + browserCircuits.set(username, circuitIds); + // FIXME: We only notify the circuit display when we have a change that + // involves circuits whose nodes are known, for now. We need to resolve a + // few other techical problems (e.g., associate the circuit to the + // document?) and develop a UX with some animation to notify the circuit + // display more often. + // See tor-browser#41700 and tor-browser!699. + // In any case, notify the circuit display only after the internal map has + // been updated. + this.#notifyCircuitDisplay(); + } else if (circuitIds.pending !== id) { + // We do not have node data, so we store that we might need to track this. + // Otherwise, when a circuit is ready, we do not know which browser was it + // used for. + circuitIds.pending = id; + browserCircuits.set(username, circuitIds); + } + } + + /** + * Update a circuit, and notify the related circuit displays if it changed. + * + * This function is called when a certain stream has succeeded and so we can + * associate its SOCKS credential to the circuit it is using. + * We receive only the fingerprints of the circuit nodes, but they are enough + * to check if the circuit has changed. If it has, we also get the nodes' + * information through the control port. + * + * @param {string} username The SOCKS username + * @param {string} password The SOCKS password + * @param {NodeFingerprint[]} circuit The fingerprints of the nodes that + * compose the circuit + */ + async #updateCircuit(username, password, circuit) { + const id = this.#credentialsToId(username, password); + let data = this.#knownCircuits.get(id) ?? []; + // Should we modify the lower layer to send a circuit identifier, instead? + if ( + circuit.length === data.length && + circuit.every((id, index) => id === data[index].fingerprint) + ) { + return; + } + + data = await Promise.all( + circuit.map(fingerprint => + lazy.TorProtocolService.getNodeInfo(fingerprint) + ) + ); + this.#knownCircuits.set(id, data); + // We know that something changed, but we cannot know if anyone is + // interested in this change. So, we have to notify all the possible + // consumers of the data in any case. + // Not being specific and let them check if they need to do something allows + // us to keep a simpler structure. + this.#notifyCircuitDisplay(); + } + + /** + * Broadcast a notification when a circuit changed, or a browser is changing + * circuit (which might happen also in case of navigation). + */ + #notifyCircuitDisplay() { + Services.obs.notifyObservers(null, TOR_CIRCUIT_TOPIC); + } + + /** + * Clear the known circuit information, when they are not needed anymore. + * + * We keep circuit data around for a while. We decouple it from the underlying + * tor circuit management in case the user clicks on the circuit display when + * circuit has long gone. + * However, data accumulate during a session. So, since we store all the + * browsers that used a circuit anyway, every now and then we check if we + * still know browsers using a certain circuits. If there are not, we forget + * about it. + * + * This function is run by an interval. + */ + #clearKnownCircuits() { + logger.info("Running the circuit cleanup"); + const windows = []; + const enumerator = Services.wm.getEnumerator("navigator:browser"); + while (enumerator.hasMoreElements()) { + windows.push(enumerator.getNext()); + } + const browsers = windows + .flatMap(win => win.gBrowser.browsers.map(b => b.browserId)) + .filter(id => this.#browsers.has(id)); + this.#browsers = new Map(browsers.map(id => [id, this.#browsers.get(id)])); + this.#knownCircuits = new Map( + Array.from(this.#browsers.values(), circuits => + Array.from(circuits.values(), ids => { + const r = []; + const current = this.#knownCircuits.get(ids.current); + if (current) { + r.push([ids.current, current]); + } + const pending = this.#knownCircuits.get(ids.pending); + if (pending) { + r.push([ids.pending, pending]); + } + return r; + }) + ).flat(2) + ); + } }
/** * Get the first party domain for a certain browser. * - * @param browser The browser to get the FP-domain for. - * + * @param {MozBrowser} browser The browser to get the FP-domain for. * Please notice that it should be gBrowser.selectedBrowser, because * browser.documentURI is the actual shown page, and might be an error page. * In this case, we rely on currentURI, which for gBrowser is an alias of @@ -358,6 +666,6 @@ function getDomainForBrowser(browser) { return fpd; }
-const TorDomainIsolator = new TorDomainIsolatorImpl(); +export const TorDomainIsolator = new TorDomainIsolatorImpl(); // Reduce global vars pollution TorDomainIsolator.getDomainForBrowser = getDomainForBrowser;
===================================== toolkit/components/tor-launcher/TorMonitorService.sys.mjs ===================================== @@ -19,6 +19,10 @@ ChromeUtils.defineModuleGetter( "resource://torbutton/modules/tor-control-port.js" );
+ChromeUtils.defineESModuleGetters(lazy, { + TorProtocolService: "resource://gre/modules/TorProtocolService.sys.mjs", +}); + const logger = new ConsoleAPI({ maxLogLevel: "warn", maxLogLevelPref: "browser.tor_monitor_service.log_level", @@ -37,12 +41,34 @@ const TorTopics = Object.freeze({ ProcessRestarted: "TorProcessRestarted", });
+export const TorMonitorTopics = Object.freeze({ + BridgeChanged: "TorBridgeChanged", + StreamSucceeded: "TorStreamSucceeded", +}); + const ControlConnTimings = Object.freeze({ initialDelayMS: 25, // Wait 25ms after the process has started, before trying to connect maxRetryMS: 10000, // Retry at most every 10 seconds timeoutMS: 5 * 60 * 1000, // Wait at most 5 minutes for tor to start });
+/** + * From control-spec.txt: + * CircuitID = 1*16 IDChar + * IDChar = ALPHA / DIGIT + * Currently, Tor only uses digits, but this may change. + * + * @typedef {string} CircuitID + */ +/** + * The fingerprint of a node. + * From control-spec.txt: + * Fingerprint = "$" 40*HEXDIG + * However, we do not keep the $ in our structures. + * + * @typedef {string} NodeFingerprint + */ + /** * This service monitors an existing Tor instance, or starts one, if needed, and * then starts monitoring it. @@ -52,7 +78,7 @@ const ControlConnTimings = Object.freeze({ */ export const TorMonitorService = { _connection: null, - _eventsToMonitor: Object.freeze(["STATUS_CLIENT", "NOTICE", "WARN", "ERR"]), + _eventHandlers: {}, _torLog: [], // Array of objects with date, type, and msg properties. _startTimeout: null,
@@ -64,6 +90,28 @@ export const TorMonitorService = {
_inited: false,
+ /** + * Stores the nodes of a circuit. Keys are cicuit IDs, and values are the node + * fingerprints. + * + * Theoretically, we could hook this map up to the new identity notification, + * but in practice it does not work. Tor pre-builds circuits, and the NEWNYM + * signal does not affect them. So, we might end up using a circuit that was + * built before the new identity but not yet used. If we cleaned the map, we + * risked of not having the data about it. + * + * @type {Map<CircuitID, NodeFingerprint[]>} + */ + _circuits: new Map(), + /** + * The last used bridge, or null if bridges are not in use or if it was not + * possible to detect the bridge. This needs the user to have specified bridge + * lines with fingerprints to work. + * + * @type {NodeFingerprint?} + */ + _currentBridge: null, + // Public methods
// Starts Tor, if needed, and starts monitoring for events @@ -72,14 +120,28 @@ export const TorMonitorService = { return; } this._inited = true; + + // We always liten to these events, because they are needed for the circuit + // display. + this._eventHandlers = new Map([ + ["CIRC", this._processCircEvent.bind(this)], + ["STREAM", this._processStreamEvent.bind(this)], + ]); + if (this.ownsTorDaemon) { + // When we own the tor daemon, we listen to more events, that are used + // for about:torconnect or for showing the logs in the settings page. + this._eventHandlers.set("STATUS_CLIENT", (_eventType, lines) => + this._processBootstrapStatus(lines[0], false) + ); + this._eventHandlers.set("NOTICE", this._processLog.bind(this)); + this._eventHandlers.set("WARN", this._processLog.bind(this)); + this._eventHandlers.set("ERR", this._processLog.bind(this)); this._controlTor(); } else { - logger.info( - "Not starting the event monitor, as we do not own the Tor daemon." - ); + this._startEventMonitor(); } - logger.debug("TorMonitorService initialized"); + logger.info("TorMonitorService initialized"); },
// Closes the connection that monitors for events. @@ -153,6 +215,18 @@ export const TorMonitorService = { return !!this._connection; },
+ /** + * Return the data about the current bridge, if any, or null. + * We can detect bridge only when the configured bridge lines include the + * fingerprints. + * + * @returns {NodeData?} The node information, or null if the first node + * is not a bridge, or no circuit has been opened, yet. + */ + get currentBridge() { + return this._currentBridge; + }, + // Private methods
async _startProcess() { @@ -272,7 +346,7 @@ export const TorMonitorService = {
// TODO: optionally monitor INFO and DEBUG log messages. let reply = await conn.sendCommand( - "SETEVENTS " + this._eventsToMonitor.join(" ") + "SETEVENTS " + Array.from(this._eventHandlers.keys()).join(" ") ); reply = TorParsers.parseCommandResponse(reply); if (!TorParsers.commandSucceeded(reply)) { @@ -281,14 +355,10 @@ export const TorMonitorService = { return false; }
- // FIXME: At the moment it is not possible to start the event monitor - // when we do start the tor process. So, does it make sense to keep this - // control? if (this._torProcess) { this._torProcess.connectionWorked(); } - - if (!TorLauncherUtil.shouldOnlyConfigureTor) { + if (this.ownsTorDaemon && !TorLauncherUtil.shouldOnlyConfigureTor) { try { await this._takeTorOwnership(conn); } catch (e) { @@ -297,7 +367,31 @@ export const TorMonitorService = { }
this._connection = conn; - this._waitForEventData(); + + for (const [type, callback] of this._eventHandlers.entries()) { + this._monitorEvent(type, callback); + } + + // Populate the circuit map already, in case we are connecting to an + // external tor daemon. + try { + const reply = await this._connection.sendCommand( + "GETINFO circuit-status" + ); + const lines = reply.split(/\r?\n/); + if (lines.shift() === "250+circuit-status=") { + for (const line of lines) { + if (line === ".") { + break; + } + // _processCircEvent processes only one line at a time + this._processCircEvent("CIRC", [line]); + } + } + } catch (e) { + logger.warn("Could not populate the initial circuit map", e); + } + return true; },
@@ -318,65 +412,49 @@ export const TorMonitorService = { } },
- _waitForEventData() { - if (!this._connection) { - return; - } - logger.debug("Start watching events:", this._eventsToMonitor); + _monitorEvent(type, callback) { + logger.info(`Watching events of type ${type}.`); let replyObj = {}; - for (const torEvent of this._eventsToMonitor) { - this._connection.watchEvent( - torEvent, - null, - line => { - if (!line) { - return; - } - logger.debug("Event response: ", line); - const isComplete = TorParsers.parseReplyLine(line, replyObj); - if (isComplete) { - this._processEventReply(replyObj); - replyObj = {}; - } - }, - true - ); - } + this._connection.watchEvent( + type, + null, + line => { + if (!line) { + return; + } + logger.debug("Event response: ", line); + const isComplete = TorParsers.parseReplyLine(line, replyObj); + if (!isComplete || replyObj._parseError || !replyObj.lineArray.length) { + return; + } + const reply = replyObj; + replyObj = {}; + if (reply.statusCode !== TorStatuses.EventNotification) { + logger.error("Unexpected event status code:", reply.statusCode); + return; + } + if (!reply.lineArray[0].startsWith(`${type} `)) { + logger.error("Wrong format for the first line:", reply.lineArray[0]); + return; + } + reply.lineArray[0] = reply.lineArray[0].substring(type.length + 1); + try { + callback(type, reply.lineArray); + } catch (e) { + logger.error("Exception while handling an event", reply, e); + } + }, + true + ); },
- _processEventReply(aReply) { - if (aReply._parseError || !aReply.lineArray.length) { - return; - } - - if (aReply.statusCode !== TorStatuses.EventNotification) { - logger.warn("Unexpected event status code:", aReply.statusCode); - return; - } - - // TODO: do we need to handle multiple lines? - const s = aReply.lineArray[0]; - const idx = s.indexOf(" "); - if (idx === -1) { - return; - } - const eventType = s.substring(0, idx); - const msg = s.substring(idx + 1).trim(); - - if (eventType === "STATUS_CLIENT") { - this._processBootstrapStatus(msg, false); - return; - } else if (!this._eventsToMonitor.includes(eventType)) { - logger.debug(`Dropping unlistened event ${eventType}`); - return; - } - - if (eventType === "WARN" || eventType === "ERR") { + _processLog(type, lines) { + if (type === "WARN" || type === "ERR") { // Notify so that Copy Log can be enabled. Services.obs.notifyObservers(null, TorTopics.HasWarnOrErr); }
- const now = new Date(); + const date = new Date(); const maxEntries = Services.prefs.getIntPref( "extensions.torlauncher.max_tor_log_entries", 1000 @@ -384,8 +462,10 @@ export const TorMonitorService = { if (maxEntries > 0 && this._torLog.length >= maxEntries) { this._torLog.splice(0, 1); } - this._torLog.push({ date: now, type: eventType, msg }); - const logString = `Tor ${eventType}: ${msg}`; + + const msg = lines.join("\n"); + this._torLog.push({ date, type, msg }); + const logString = `Tor ${type}: ${msg}`; logger.info(logString); },
@@ -461,8 +541,108 @@ export const TorMonitorService = { } },
+ async _processCircEvent(_type, lines) { + const builtEvent = + /^(?<CircuitID>[a-zA-Z0-9]{1,16})\sBUILT\s(?<Path>(?:,?$[0-9a-fA-F]{40}(?:~[a-zA-Z0-9]{1,19})?)+)/.exec( + lines[0] + ); + const closedEvent = /^(?<ID>[a-zA-Z0-9]{1,16})\sCLOSED/.exec(lines[0]); + if (builtEvent) { + const fp = /$([0-9a-fA-F]{40})/g; + const nodes = Array.from(builtEvent.groups.Path.matchAll(fp), g => + g[1].toUpperCase() + ); + this._circuits.set(builtEvent.groups.CircuitID, nodes); + // Ignore circuits of length 1, that are used, for example, to probe + // bridges. So, only store them, since we might see streams that use them, + // but then early-return. + if (nodes.length === 1) { + return; + } + // In some cases, we might already receive SOCKS credentials in the line. + // However, this might be a problem with onion services: we get also a + // 4-hop circuit that we likely do not want to show to the user, + // especially because it is used only temporarily, and it would need a + // technical explaination. + // this._checkCredentials(lines[0], nodes); + if (this._currentBridge?.fingerprint !== nodes[0]) { + const nodeInfo = await lazy.TorProtocolService.getNodeInfo(nodes[0]); + let notify = false; + if (nodeInfo?.bridgeType) { + logger.info(`Bridge changed to ${nodes[0]}`); + this._currentBridge = nodeInfo; + notify = true; + } else if (this._currentBridge) { + logger.info("Bridges disabled"); + this._currentBridge = null; + notify = true; + } + if (notify) { + Services.obs.notifyObservers( + null, + TorMonitorTopics.BridgeChanged, + this._currentBridge + ); + } + } + } else if (closedEvent) { + this._circuits.delete(closedEvent.groups.ID); + } + }, + + _processStreamEvent(_type, lines) { + // The first block is the stream ID, which we do not need at the moment. + const succeeedEvent = + /^[a-zA-Z0-9]{1,16}\sSUCCEEDED\s(?<CircuitID>[a-zA-Z0-9]{1,16})/.exec( + lines[0] + ); + if (!succeeedEvent) { + return; + } + const circuit = this._circuits.get(succeeedEvent.groups.CircuitID); + if (!circuit) { + logger.error( + "Seen a STREAM SUCCEEDED with an unknown circuit. Not notifying observers.", + lines[0] + ); + return; + } + this._checkCredentials(lines[0], circuit); + }, + + /** + * Check if a STREAM or CIRC response line contains SOCKS_USERNAME and + * SOCKS_PASSWORD. In case, notify observers that we could associate a certain + * circuit to these credentials. + * + * @param {string} line The circ or stream line to check + * @param {NodeFingerprint[]} circuit The fingerprints of the nodes in the + * circuit. + */ + _checkCredentials(line, circuit) { + const username = /SOCKS_USERNAME=("(?:[^"\]|\.)*")/.exec(line); + const password = /SOCKS_PASSWORD=("(?:[^"\]|\.)*")/.exec(line); + if (!username || !password) { + return; + } + Services.obs.notifyObservers( + { + wrappedJSObject: { + username: TorParsers.unescapeString(username[1]), + password: TorParsers.unescapeString(password[1]), + circuit, + }, + }, + TorMonitorTopics.StreamSucceeded + ); + }, + _shutDownEventMonitor() { - this._connection?.close(); + try { + this._connection?.close(); + } catch (e) { + logger.error("Could not close the connection to the control port", e); + } this._connection = null; if (this._startTimeout !== null) { clearTimeout(this._startTimeout);
===================================== toolkit/components/tor-launcher/TorParsers.sys.mjs ===================================== @@ -181,12 +181,12 @@ export const TorParsers = Object.freeze({ return aStr; } const escaped = aStr - .replace("\", "\\") - .replace('"', '\"') - .replace("\n", "\n") - .replace("\r", "\r") - .replace("\t", "\t") - .replace(/[^\x20-\x7e]+/g, text => { + .replaceAll("\", "\\") + .replaceAll('"', '\"') + .replaceAll("\n", "\n") + .replaceAll("\r", "\r") + .replaceAll("\t", "\t") + .replaceAll(/[^\x20-\x7e]+/g, text => { const encoder = new TextEncoder(); return Array.from( encoder.encode(text),
===================================== toolkit/components/tor-launcher/TorProtocolService.sys.mjs ===================================== @@ -40,6 +40,20 @@ const logger = new ConsoleAPI({ prefix: "TorProtocolService", });
+/** + * Stores the data associated with a circuit node. + * + * @typedef NodeData + * @property {string} fingerprint The node fingerprint. + * @property {string[]} ipAddrs - The ip addresses associated with this node. + * @property {string?} bridgeType - The bridge type for this node, or "" if the + * node is a bridge but the type is unknown, or null if this is not a bridge + * node. + * @property {string?} regionCode - An upper case 2-letter ISO3166-1 code for + * the first ip address, or null if there is no region. This should also be a + * valid BCP47 Region subtag. + */ + // Manage the connection to tor's control port, to update its settings and query // other useful information. // @@ -188,6 +202,89 @@ export const TorProtocolService = { return TorParsers.parseReply(cmd, keyword, response); },
+ async getBridges() { + // Ideally, we would not need this function, because we should be the one + // setting them with TorSettings. However, TorSettings is not notified of + // change of settings. So, asking tor directly with the control connection + // is the most reliable way of getting the configured bridges, at the + // moment. Also, we are using this for the circuit display, which should + // work also when we are not configuring the tor daemon, but just using it. + return this._withConnection(conn => { + return conn.getConf("bridge"); + }); + }, + + /** + * Returns tha data about a relay or a bridge. + * + * @param {string} id The fingerprint of the node to get data about + * @returns {NodeData} + */ + async getNodeInfo(id) { + return this._withConnection(async conn => { + const node = { + fingerprint: id, + ipAddrs: [], + bridgeType: null, + regionCode: null, + }; + const bridge = (await conn.getConf("bridge"))?.find( + foundBridge => foundBridge.ID?.toUpperCase() === id.toUpperCase() + ); + const addrRe = /^[?([^]]+)]?:\d+$/; + if (bridge) { + node.bridgeType = bridge.type ?? ""; + // Attempt to get an IP address from bridge address string. + const ip = bridge.address.match(addrRe)?.[1]; + if (ip && !ip.startsWith("0.")) { + node.ipAddrs.push(ip); + } + } else { + // Either dealing with a relay, or a bridge whose fingerprint is not + // saved in torrc. + const info = await conn.getInfo(`ns/id/${id}`); + if (info.IP && !info.IP.startsWith("0.")) { + node.ipAddrs.push(info.IP); + } + const ip6 = info.IPv6?.match(addrRe)?.[1]; + if (ip6) { + node.ipAddrs.push(ip6); + } + } + if (node.ipAddrs.length) { + // Get the country code for the node's IP address. + let regionCode; + try { + // Expect a 2-letter ISO3166-1 code, which should also be a valid + // BCP47 Region subtag. + regionCode = await conn.getInfo("ip-to-country/" + node.ipAddrs[0]); + } catch {} + if (regionCode && regionCode !== "??") { + node.regionCode = regionCode.toUpperCase(); + } + } + return node; + }); + }, + + async onionAuthAdd(hsAddress, b64PrivateKey, isPermanent) { + return this._withConnection(conn => { + return conn.onionAuthAdd(hsAddress, b64PrivateKey, isPermanent); + }); + }, + + async onionAuthRemove(hsAddress) { + return this._withConnection(conn => { + return conn.onionAuthRemove(hsAddress); + }); + }, + + async onionAuthViewKeys() { + return this._withConnection(conn => { + return conn.onionAuthViewKeys(); + }); + }, + // TODO: transform the following 4 functions in getters. At the moment they // are also used in torbutton.
@@ -630,6 +727,16 @@ export const TorProtocolService = { } },
+ async _withConnection(func) { + // TODO: Make more robust? + const conn = await this._getConnection(); + try { + return await func(conn); + } finally { + this._returnConnection(); + } + }, + // If aConn is omitted, the cached connection is closed. _closeConnection() { if (this._controlConnection) {
===================================== toolkit/components/tor-launcher/TorStartupService.sys.mjs ===================================== @@ -3,6 +3,7 @@ const lazy = {}; // We will use the modules only when the profile is loaded, so prefer lazy // loading ChromeUtils.defineESModuleGetters(lazy, { + TorDomainIsolator: "resource://gre/modules/TorDomainIsolator.sys.mjs", TorLauncherUtil: "resource://gre/modules/TorLauncherUtil.sys.mjs", TorMonitorService: "resource://gre/modules/TorMonitorService.sys.mjs", TorProtocolService: "resource://gre/modules/TorProtocolService.sys.mjs", @@ -19,12 +20,6 @@ ChromeUtils.defineModuleGetter( "resource:///modules/TorSettings.jsm" );
-ChromeUtils.defineModuleGetter( - lazy, - "TorDomainIsolator", - "resource://gre/modules/TorDomainIsolator.jsm" -); - /* Browser observer topis */ const BrowserTopics = Object.freeze({ ProfileAfterChange: "profile-after-change",
===================================== toolkit/components/tor-launcher/moz.build ===================================== @@ -1,6 +1,6 @@ EXTRA_JS_MODULES += [ "TorBootstrapRequest.sys.mjs", - "TorDomainIsolator.jsm", + "TorDomainIsolator.sys.mjs", "TorLauncherUtil.sys.mjs", "TorMonitorService.sys.mjs", "TorParsers.sys.mjs",
View it on GitLab: https://gitlab.torproject.org/tpo/applications/tor-browser/-/compare/bdda467...
tbb-commits@lists.torproject.org