[tbb-commits] [Git][tpo/applications/tor-browser][tor-browser-115.1.0esr-13.0-1] 7 commits: fixup! Bug 40933: Add tor-launcher functionality

richard (@richard) git at gitlab.torproject.org
Thu Jul 27 18:01:01 UTC 2023



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/bdda467fbaec13a0dc9ad40997d0261f7396f5da...1a8be7b1e2fd495282857ea7b17972ed8892d133

-- 
View it on GitLab: https://gitlab.torproject.org/tpo/applications/tor-browser/-/compare/bdda467fbaec13a0dc9ad40997d0261f7396f5da...1a8be7b1e2fd495282857ea7b17972ed8892d133
You're receiving this email because of your account on gitlab.torproject.org.


-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.torproject.org/pipermail/tbb-commits/attachments/20230727/11259267/attachment-0001.htm>


More information about the tbb-commits mailing list