[tbb-commits] [tor-browser/tor-browser-78.6.0esr-10.5-1] Bug 30237: Add v3 onion services client authentication prompt

sysrqb at torproject.org sysrqb at torproject.org
Wed Dec 9 17:37:13 UTC 2020


commit 426c6097166a6fecde17c4ce88b6f25387ea3b98
Author: Kathy Brade <brade at pearlcrescent.com>
Date:   Tue Nov 12 16:11:05 2019 -0500

    Bug 30237: Add v3 onion services client authentication prompt
    
    When Tor informs the browser that client authentication is needed,
    temporarily load about:blank instead of about:neterror and prompt
    for the user's key.
    
    If a correctly formatted key is entered, use Tor's ONION_CLIENT_AUTH_ADD
    control port command to add the key (via Torbutton's control port
    module) and reload the page.
    
    If the user cancels the prompt, display the standard about:neterror
    "Unable to connect" page. This requires a small change to
    browser/actors/NetErrorChild.jsm to account for the fact that the
    docShell no longer has the failedChannel information. The failedChannel
    is used to extract TLS-related error info, which is not applicable
    in the case of a canceled .onion authentication prompt.
    
    Add a leaveOpen option to PopupNotifications.show so we can display
    error messages within the popup notification doorhanger without
    closing the prompt.
    
    Add support for onion services strings to the TorStrings module.
    
    Add support for Tor extended SOCKS errors (Tor proposal 304) to the
    socket transport and SOCKS layers. Improved display of all of these
    errors will be implemented as part of bug 30025.
    
    Also fixes bug 19757:
     Add a "Remember this key" checkbox to the client auth prompt.
    
     Add an "Onion Services Authentication" section within the
     about:preferences "Privacy & Security section" to allow
     viewing and removal of v3 onion client auth keys that have
     been stored on disk.
    
    Also fixes bug 19251: use enhanced error pages for onion service errors.
---
 browser/actors/NetErrorChild.jsm                   |   7 +
 browser/base/content/aboutNetError.js              |  10 +-
 browser/base/content/aboutNetError.xhtml           |   1 +
 browser/base/content/browser.js                    |  10 +
 browser/base/content/browser.xhtml                 |   3 +
 browser/base/content/tab-content.js                |   5 +
 browser/components/moz.build                       |   1 +
 .../content/authNotificationIcon.inc.xhtml         |   6 +
 .../onionservices/content/authPopup.inc.xhtml      |  16 ++
 .../onionservices/content/authPreferences.css      |  20 ++
 .../content/authPreferences.inc.xhtml              |  19 ++
 .../onionservices/content/authPreferences.js       |  66 +++++
 .../components/onionservices/content/authPrompt.js | 316 +++++++++++++++++++++
 .../components/onionservices/content/authUtil.jsm  |  47 +++
 .../onionservices/content/netError/browser.svg     |   3 +
 .../onionservices/content/netError/network.svg     |   3 +
 .../content/netError/onionNetError.css             |  65 +++++
 .../content/netError/onionNetError.js              | 244 ++++++++++++++++
 .../onionservices/content/netError/onionsite.svg   |   7 +
 .../onionservices/content/onionservices.css        |  69 +++++
 .../onionservices/content/savedKeysDialog.js       | 259 +++++++++++++++++
 .../onionservices/content/savedKeysDialog.xhtml    |  42 +++
 browser/components/onionservices/jar.mn            |   9 +
 browser/components/onionservices/moz.build         |   1 +
 browser/components/preferences/preferences.xhtml   |   1 +
 browser/components/preferences/privacy.inc.xhtml   |   2 +
 browser/components/preferences/privacy.js          |   7 +
 browser/themes/shared/notification-icons.inc.css   |   3 +
 docshell/base/nsDocShell.cpp                       |  81 +++++-
 dom/ipc/BrowserParent.cpp                          |  21 ++
 dom/ipc/BrowserParent.h                            |   3 +
 dom/ipc/PBrowser.ipdl                              |   9 +
 js/xpconnect/src/xpc.msg                           |  10 +
 netwerk/base/nsSocketTransport2.cpp                |   6 +
 netwerk/socket/nsSOCKSIOLayer.cpp                  |  49 ++++
 toolkit/modules/PopupNotifications.jsm             |   6 +
 toolkit/modules/RemotePageAccessManager.jsm        |   1 +
 .../lib/environments/frame-script.js               |   1 +
 xpcom/base/ErrorList.py                            |  22 ++
 39 files changed, 1449 insertions(+), 2 deletions(-)

diff --git a/browser/actors/NetErrorChild.jsm b/browser/actors/NetErrorChild.jsm
index af9d6bd46128..de66e9eeda18 100644
--- a/browser/actors/NetErrorChild.jsm
+++ b/browser/actors/NetErrorChild.jsm
@@ -13,6 +13,8 @@ const { RemotePageChild } = ChromeUtils.import(
   "resource://gre/actors/RemotePageChild.jsm"
 );
 
+const { TorStrings } = ChromeUtils.import("resource:///modules/TorStrings.jsm");
+
 XPCOMUtils.defineLazyServiceGetter(
   this,
   "gSerializationHelper",
@@ -29,6 +31,7 @@ class NetErrorChild extends RemotePageChild {
       "RPMPrefIsLocked",
       "RPMAddToHistogram",
       "RPMRecordTelemetryEvent",
+      "RPMGetTorStrings",
     ];
     this.exportFunctions(exportableFunctions);
   }
@@ -82,4 +85,8 @@ class NetErrorChild extends RemotePageChild {
   RPMRecordTelemetryEvent(category, event, object, value, extra) {
     Services.telemetry.recordEvent(category, event, object, value, extra);
   }
+
+  RPMGetTorStrings() {
+    return Cu.cloneInto(TorStrings.onionServices, this.contentWindow);
+  }
 }
diff --git a/browser/base/content/aboutNetError.js b/browser/base/content/aboutNetError.js
index 053d26ade512..60db17f46eb9 100644
--- a/browser/base/content/aboutNetError.js
+++ b/browser/base/content/aboutNetError.js
@@ -3,6 +3,7 @@
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* eslint-env mozilla/frame-script */
+/* import-globals-from ../../components/onionservices/content/netError/onionNetError.js */
 
 const formatter = new Intl.DateTimeFormat("default");
 
@@ -241,7 +242,10 @@ function initPage() {
     errDesc = document.getElementById("ed_generic");
   }
 
-  setErrorPageStrings(err);
+  const isOnionError = err.startsWith("onionServices.");
+  if (!isOnionError) {
+    setErrorPageStrings(err);
+  }
 
   var sd = document.getElementById("errorShortDescText");
   if (sd) {
@@ -387,6 +391,10 @@ function initPage() {
       span.textContent = document.location.hostname;
     }
   }
+
+  if (isOnionError) {
+    OnionServicesAboutNetError.initPage(document);
+  }
 }
 
 function setupErrorUI() {
diff --git a/browser/base/content/aboutNetError.xhtml b/browser/base/content/aboutNetError.xhtml
index 3e9677e3c0ff..957b6f15a0be 100644
--- a/browser/base/content/aboutNetError.xhtml
+++ b/browser/base/content/aboutNetError.xhtml
@@ -208,5 +208,6 @@
       </div>
     </div>
   </body>
+  <script src="chrome://browser/content/onionservices/netError/onionNetError.js"/>
   <script src="chrome://browser/content/aboutNetError.js"/>
 </html>
diff --git a/browser/base/content/browser.js b/browser/base/content/browser.js
index d192bc433fe6..6246068e88df 100644
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -220,6 +220,11 @@ XPCOMUtils.defineLazyScriptGetter(
   ["SecurityLevelButton"],
   "chrome://browser/content/securitylevel/securityLevel.js"
 );
+XPCOMUtils.defineLazyScriptGetter(
+  this,
+  ["OnionAuthPrompt"],
+  "chrome://browser/content/onionservices/authPrompt.js"
+);
 XPCOMUtils.defineLazyScriptGetter(
   this,
   "gEditItemOverlay",
@@ -1883,6 +1888,9 @@ var gBrowserInit = {
     // Init the SecuritySettingsButton
     SecurityLevelButton.init();
 
+    // Init the OnionAuthPrompt
+    OnionAuthPrompt.init();
+
     // Certain kinds of automigration rely on this notification to complete
     // their tasks BEFORE the browser window is shown. SessionStore uses it to
     // restore tabs into windows AFTER important parts like gMultiProcessBrowser
@@ -2567,6 +2575,8 @@ var gBrowserInit = {
 
     SecurityLevelButton.uninit();
 
+    OnionAuthPrompt.uninit();
+
     gAccessibilityServiceIndicator.uninit();
 
     AccessibilityRefreshBlocker.uninit();
diff --git a/browser/base/content/browser.xhtml b/browser/base/content/browser.xhtml
index ddf5d766126b..4cab5fad6475 100644
--- a/browser/base/content/browser.xhtml
+++ b/browser/base/content/browser.xhtml
@@ -33,6 +33,7 @@
 <?xml-stylesheet href="chrome://browser/skin/places/editBookmark.css" type="text/css"?>
 <?xml-stylesheet href="chrome://torbutton/skin/tor-circuit-display.css" type="text/css"?>
 <?xml-stylesheet href="chrome://torbutton/skin/torbutton.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/content/onionservices/onionservices.css" type="text/css"?>
 
 # All DTD information is stored in a separate file so that it can be shared by
 # hiddenWindowMac.xhtml.
@@ -626,6 +627,7 @@
 #include ../../components/downloads/content/downloadsPanel.inc.xhtml
 #include ../../../devtools/startup/enableDevToolsPopup.inc.xhtml
 #include ../../components/securitylevel/content/securityLevelPanel.inc.xhtml
+#include ../../components/onionservices/content/authPopup.inc.xhtml
 #include browser-allTabsMenu.inc.xhtml
 
     <hbox id="downloads-animation-container">
@@ -994,6 +996,7 @@
                          data-l10n-id="urlbar-indexed-db-notification-anchor"/>
                   <image id="password-notification-icon" class="notification-anchor-icon login-icon" role="button"
                          data-l10n-id="urlbar-password-notification-anchor"/>
+#include ../../components/onionservices/content/authNotificationIcon.inc.xhtml
                   <stack id="plugins-notification-icon" class="notification-anchor-icon" role="button" align="center" data-l10n-id="urlbar-plugins-notification-anchor">
                     <image class="plugin-icon" />
                     <image id="plugin-icon-badge" />
diff --git a/browser/base/content/tab-content.js b/browser/base/content/tab-content.js
index 30cfa891c1fb..c57244a962ee 100644
--- a/browser/base/content/tab-content.js
+++ b/browser/base/content/tab-content.js
@@ -19,6 +19,9 @@ ChromeUtils.defineModuleGetter(
   "BrowserUtils",
   "resource://gre/modules/BrowserUtils.jsm"
 );
+var { OnionAuthUtil } = ChromeUtils.import(
+  "chrome://browser/content/onionservices/authUtil.jsm"
+);
 
 var { ActorManagerChild } = ChromeUtils.import(
   "resource://gre/modules/ActorManagerChild.jsm"
@@ -101,5 +104,7 @@ if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT) {
 
 Services.obs.notifyObservers(this, "tab-content-frameloader-created");
 
+OnionAuthUtil.addCancelMessageListener(this, docShell);
+
 // This is a temporary hack to prevent regressions (bug 1471327).
 void content;
diff --git a/browser/components/moz.build b/browser/components/moz.build
index 09e209dc9c3b..b660be047b14 100644
--- a/browser/components/moz.build
+++ b/browser/components/moz.build
@@ -41,6 +41,7 @@ DIRS += [
     'fxmonitor',
     'migration',
     'newtab',
+    'onionservices',
     'originattributes',
     'pioneer',
     'places',
diff --git a/browser/components/onionservices/content/authNotificationIcon.inc.xhtml b/browser/components/onionservices/content/authNotificationIcon.inc.xhtml
new file mode 100644
index 000000000000..91274d612739
--- /dev/null
+++ b/browser/components/onionservices/content/authNotificationIcon.inc.xhtml
@@ -0,0 +1,6 @@
+# Copyright (c) 2020, The Tor Project, Inc.
+
+<image id="tor-clientauth-notification-icon"
+       class="notification-anchor-icon tor-clientauth-icon"
+       role="button"
+       tooltiptext="&torbutton.onionServices.authPrompt.tooltip;"/>
diff --git a/browser/components/onionservices/content/authPopup.inc.xhtml b/browser/components/onionservices/content/authPopup.inc.xhtml
new file mode 100644
index 000000000000..bd0ec3aa0b00
--- /dev/null
+++ b/browser/components/onionservices/content/authPopup.inc.xhtml
@@ -0,0 +1,16 @@
+# Copyright (c) 2020, The Tor Project, Inc.
+
+<popupnotification id="tor-clientauth-notification" hidden="true">
+  <popupnotificationcontent orient="vertical">
+    <description id="tor-clientauth-notification-desc"/>
+    <label id="tor-clientauth-notification-learnmore"
+           class="text-link popup-notification-learnmore-link"
+           is="text-link"/>
+    <html:div>
+      <html:input id="tor-clientauth-notification-key" type="password"/>
+      <html:div id="tor-clientauth-warning"/>
+      <checkbox id="tor-clientauth-persistkey-checkbox"
+                label="&torbutton.onionServices.authPrompt.persistCheckboxLabel;"/>
+    </html:div>
+  </popupnotificationcontent>
+</popupnotification>
diff --git a/browser/components/onionservices/content/authPreferences.css b/browser/components/onionservices/content/authPreferences.css
new file mode 100644
index 000000000000..b3fb79b26ddc
--- /dev/null
+++ b/browser/components/onionservices/content/authPreferences.css
@@ -0,0 +1,20 @@
+/* Copyright (c) 2020, The Tor Project, Inc. */
+
+#torOnionServiceKeys-overview-container {
+  margin-right: 30px;
+}
+
+#onionservices-savedkeys-tree treechildren::-moz-tree-cell-text {
+  font-size: 80%;
+}
+
+#onionservices-savedkeys-errorContainer {
+  margin-top: 4px;
+  min-height: 3em;
+}
+
+#onionservices-savedkeys-errorIcon {
+  margin-right: 4px;
+  list-style-image: url("chrome://browser/skin/warning.svg");
+  visibility: hidden;
+}
diff --git a/browser/components/onionservices/content/authPreferences.inc.xhtml b/browser/components/onionservices/content/authPreferences.inc.xhtml
new file mode 100644
index 000000000000..f69c9dde66a2
--- /dev/null
+++ b/browser/components/onionservices/content/authPreferences.inc.xhtml
@@ -0,0 +1,19 @@
+# Copyright (c) 2020, The Tor Project, Inc.
+
+<groupbox id="torOnionServiceKeys" orient="vertical"
+          data-category="panePrivacy" hidden="true">
+  <label><html:h2 id="torOnionServiceKeys-header"/></label>
+  <hbox>
+    <description id="torOnionServiceKeys-overview-container" flex="1">
+      <html:span id="torOnionServiceKeys-overview"
+                 class="tail-with-learn-more"/>
+      <label id="torOnionServiceKeys-learnMore" class="learnMore text-link"
+             is="text-link"/>
+    </description>
+    <vbox align="end">
+      <button id="torOnionServiceKeys-savedKeys"
+              is="highlightable-button"
+              class="accessory-button"/>
+    </vbox>
+  </hbox>
+</groupbox>
diff --git a/browser/components/onionservices/content/authPreferences.js b/browser/components/onionservices/content/authPreferences.js
new file mode 100644
index 000000000000..52f8272020cc
--- /dev/null
+++ b/browser/components/onionservices/content/authPreferences.js
@@ -0,0 +1,66 @@
+// Copyright (c) 2020, The Tor Project, Inc.
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+  this,
+  "TorStrings",
+  "resource:///modules/TorStrings.jsm"
+);
+
+/*
+  Onion Services Client Authentication Preferences Code
+
+  Code to handle init and update of onion services authentication section
+  in about:preferences#privacy
+*/
+
+const OnionServicesAuthPreferences = {
+  selector: {
+    groupBox: "#torOnionServiceKeys",
+    header: "#torOnionServiceKeys-header",
+    overview: "#torOnionServiceKeys-overview",
+    learnMore: "#torOnionServiceKeys-learnMore",
+    savedKeysButton: "#torOnionServiceKeys-savedKeys",
+  },
+
+  init() {
+    // populate XUL with localized strings
+    this._populateXUL();
+  },
+
+  _populateXUL() {
+    const groupbox = document.querySelector(this.selector.groupBox);
+
+    let elem = groupbox.querySelector(this.selector.header);
+    elem.textContent = TorStrings.onionServices.authPreferences.header;
+
+    elem = groupbox.querySelector(this.selector.overview);
+    elem.textContent = TorStrings.onionServices.authPreferences.overview;
+
+    elem = groupbox.querySelector(this.selector.learnMore);
+    elem.setAttribute("value", TorStrings.onionServices.learnMore);
+    elem.setAttribute("href", TorStrings.onionServices.learnMoreURL);
+
+    elem = groupbox.querySelector(this.selector.savedKeysButton);
+    elem.setAttribute(
+      "label",
+      TorStrings.onionServices.authPreferences.savedKeys
+    );
+    elem.addEventListener("command", () =>
+      OnionServicesAuthPreferences.onViewSavedKeys()
+    );
+  },
+
+  onViewSavedKeys() {
+    gSubDialog.open(
+      "chrome://browser/content/onionservices/savedKeysDialog.xhtml"
+    );
+  },
+}; // OnionServicesAuthPreferences
+
+Object.defineProperty(this, "OnionServicesAuthPreferences", {
+  value: OnionServicesAuthPreferences,
+  enumerable: true,
+  writable: false,
+});
diff --git a/browser/components/onionservices/content/authPrompt.js b/browser/components/onionservices/content/authPrompt.js
new file mode 100644
index 000000000000..d4a59ac46487
--- /dev/null
+++ b/browser/components/onionservices/content/authPrompt.js
@@ -0,0 +1,316 @@
+// Copyright (c) 2020, The Tor Project, Inc.
+
+"use strict";
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  OnionAuthUtil: "chrome://browser/content/onionservices/authUtil.jsm",
+  CommonUtils: "resource://services-common/utils.js",
+  TorStrings: "resource:///modules/TorStrings.jsm",
+});
+
+const OnionAuthPrompt = (function() {
+  // OnionServicesAuthPrompt objects run within the main/chrome process.
+  // aReason is the topic passed within the observer notification that is
+  // causing this auth prompt to be displayed.
+  function OnionServicesAuthPrompt(aBrowser, aFailedURI, aReason, aOnionName) {
+    this._browser = aBrowser;
+    this._failedURI = aFailedURI;
+    this._reasonForPrompt = aReason;
+    this._onionName = aOnionName;
+  }
+
+  OnionServicesAuthPrompt.prototype = {
+    show(aWarningMessage) {
+      let mainAction = {
+        label: TorStrings.onionServices.authPrompt.done,
+        accessKey: TorStrings.onionServices.authPrompt.doneAccessKey,
+        leaveOpen: true, // Callback is responsible for closing the notification.
+        callback: this._onDone.bind(this),
+      };
+
+      let dialogBundle = Services.strings.createBundle(
+                           "chrome://global/locale/dialog.properties");
+
+      let cancelAccessKey = dialogBundle.GetStringFromName("accesskey-cancel");
+      if (!cancelAccessKey)
+        cancelAccessKey = "c"; // required by PopupNotifications.show()
+
+      let cancelAction = {
+        label: dialogBundle.GetStringFromName("button-cancel"),
+        accessKey: cancelAccessKey,
+        callback: this._onCancel.bind(this),
+      };
+
+      let _this = this;
+      let options = {
+        autofocus: true,
+        hideClose: true,
+        persistent: true,
+        removeOnDismissal: false,
+        eventCallback(aTopic) {
+          if (aTopic === "showing") {
+            _this._onPromptShowing(aWarningMessage);
+          } else if (aTopic === "shown") {
+            _this._onPromptShown();
+          } else if (aTopic === "removed") {
+            _this._onPromptRemoved();
+          }
+        }
+      };
+
+      this._prompt = PopupNotifications.show(this._browser,
+                       OnionAuthUtil.domid.notification, "",
+                       OnionAuthUtil.domid.anchor,
+                       mainAction, [cancelAction], options);
+    },
+
+    _onPromptShowing(aWarningMessage) {
+      let xulDoc = this._browser.ownerDocument;
+      let descElem = xulDoc.getElementById(OnionAuthUtil.domid.description);
+      if (descElem) {
+        // Handle replacement of the onion name within the localized
+        // string ourselves so we can show the onion name as bold text.
+        // We do this by splitting the localized string and creating
+        // several HTML <span> elements.
+        while (descElem.firstChild)
+          descElem.removeChild(descElem.firstChild);
+
+        let fmtString = TorStrings.onionServices.authPrompt.description;
+        let prefix = "";
+        let suffix = "";
+        const kToReplace = "%S";
+        let idx = fmtString.indexOf(kToReplace);
+        if (idx < 0) {
+          prefix = fmtString;
+        } else {
+          prefix = fmtString.substring(0, idx);
+          suffix = fmtString.substring(idx + kToReplace.length);
+        }
+
+        const kHTMLNS = "http://www.w3.org/1999/xhtml";
+        let span = xulDoc.createElementNS(kHTMLNS, "span");
+        span.textContent = prefix;
+        descElem.appendChild(span);
+        span = xulDoc.createElementNS(kHTMLNS, "span");
+        span.id = OnionAuthUtil.domid.onionNameSpan;
+        span.textContent = this._onionName;
+        descElem.appendChild(span);
+        span = xulDoc.createElementNS(kHTMLNS, "span");
+        span.textContent = suffix;
+        descElem.appendChild(span);
+      }
+
+      // Set "Learn More" label and href.
+      let learnMoreElem = xulDoc.getElementById(OnionAuthUtil.domid.learnMore);
+      if (learnMoreElem) {
+        learnMoreElem.setAttribute("value", TorStrings.onionServices.learnMore);
+        learnMoreElem.setAttribute("href", TorStrings.onionServices.learnMoreURL);
+      }
+
+      this._showWarning(aWarningMessage);
+      let checkboxElem = this._getCheckboxElement();
+      if (checkboxElem) {
+        checkboxElem.checked = false;
+      }
+    },
+
+    _onPromptShown() {
+      let keyElem = this._getKeyElement();
+      if (keyElem) {
+        keyElem.setAttribute("placeholder",
+                          TorStrings.onionServices.authPrompt.keyPlaceholder);
+        this._boundOnKeyFieldKeyPress = this._onKeyFieldKeyPress.bind(this);
+        this._boundOnKeyFieldInput = this._onKeyFieldInput.bind(this);
+        keyElem.addEventListener("keypress", this._boundOnKeyFieldKeyPress);
+        keyElem.addEventListener("input", this._boundOnKeyFieldInput);
+        keyElem.focus();
+      }
+    },
+
+    _onPromptRemoved() {
+      if (this._boundOnKeyFieldKeyPress) {
+        let keyElem = this._getKeyElement();
+        if (keyElem) {
+          keyElem.value = "";
+          keyElem.removeEventListener("keypress",
+                                      this._boundOnKeyFieldKeyPress);
+          this._boundOnKeyFieldKeyPress = undefined;
+          keyElem.removeEventListener("input", this._boundOnKeyFieldInput);
+          this._boundOnKeyFieldInput = undefined;
+        }
+      }
+    },
+
+    _onKeyFieldKeyPress(aEvent) {
+      if (aEvent.keyCode == aEvent.DOM_VK_RETURN) {
+        this._onDone();
+      } else if (aEvent.keyCode == aEvent.DOM_VK_ESCAPE) {
+        this._prompt.remove();
+        this._onCancel();
+      }
+    },
+
+    _onKeyFieldInput(aEvent) {
+      this._showWarning(undefined); // Remove the warning.
+    },
+
+    _onDone() {
+      let keyElem = this._getKeyElement();
+      if (!keyElem)
+        return;
+
+      let base64key = this._keyToBase64(keyElem.value);
+      if (!base64key) {
+        this._showWarning(TorStrings.onionServices.authPrompt.invalidKey);
+        return;
+      }
+
+      this._prompt.remove();
+
+      // Use Torbutton's controller module to add the private key to Tor.
+      let controllerFailureMsg =
+        TorStrings.onionServices.authPrompt.failedToSetKey;
+      try {
+        let { controller } =
+            Cu.import("resource://torbutton/modules/tor-control-port.js", {});
+        let torController = controller(aError => {
+          this.show(controllerFailureMsg);
+        });
+        let onionAddr = this._onionName.toLowerCase().replace(/\.onion$/, "");
+        let checkboxElem = this._getCheckboxElement();
+        let isPermanent = (checkboxElem && checkboxElem.checked);
+        torController.onionAuthAdd(onionAddr, base64key, isPermanent)
+        .then(aResponse => {
+          // Success! Reload the page.
+          this._browser.sendMessageToActor(
+            "Browser:Reload",
+            {},
+            "BrowserTab"
+          );
+        })
+        .catch(aError => {
+          if (aError.torMessage)
+            this.show(aError.torMessage);
+          else
+            this.show(controllerFailureMsg);
+        });
+      } catch (e) {
+        this.show(controllerFailureMsg);
+      }
+    },
+
+    _onCancel() {
+      // Arrange for an error page to be displayed.
+      this._browser.messageManager.sendAsyncMessage(
+                               OnionAuthUtil.message.authPromptCanceled,
+                               {failedURI: this._failedURI.spec,
+                                reasonForPrompt: this._reasonForPrompt});
+    },
+
+    _getKeyElement() {
+      let xulDoc = this._browser.ownerDocument;
+      return xulDoc.getElementById(OnionAuthUtil.domid.keyElement);
+    },
+
+    _getCheckboxElement() {
+      let xulDoc = this._browser.ownerDocument;
+      return xulDoc.getElementById(OnionAuthUtil.domid.checkboxElement);
+    },
+
+    _showWarning(aWarningMessage) {
+      let xulDoc = this._browser.ownerDocument;
+      let warningElem =
+                 xulDoc.getElementById(OnionAuthUtil.domid.warningElement);
+      let keyElem = this._getKeyElement();
+      if (warningElem) {
+        if (aWarningMessage) {
+          warningElem.textContent = aWarningMessage;
+          warningElem.removeAttribute("hidden");
+          if (keyElem)
+            keyElem.className = "invalid";
+        } else {
+          warningElem.setAttribute("hidden", "true");
+          if (keyElem)
+            keyElem.className = "";
+        }
+      }
+    },
+
+    // Returns undefined if the key is the wrong length or format.
+    _keyToBase64(aKeyString) {
+      if (!aKeyString)
+        return undefined;
+
+      let base64key;
+      if (aKeyString.length == 52) {
+        // The key is probably base32-encoded. Attempt to decode.
+        // Although base32 specifies uppercase letters, we accept lowercase
+        // as well because users may type in lowercase or copy a key out of
+        // a tor onion-auth file (which uses lowercase).
+        let rawKey;
+        try {
+          rawKey = CommonUtils.decodeBase32(aKeyString.toUpperCase());
+        } catch (e) {}
+
+        if (rawKey) try {
+          base64key = btoa(rawKey);
+        } catch (e) {}
+      } else if ((aKeyString.length == 44) &&
+                 /^[a-zA-Z0-9+/]*=*$/.test(aKeyString)) {
+        // The key appears to be a correctly formatted base64 value. If not,
+        // tor will return an error when we try to add the key via the
+        // control port.
+        base64key = aKeyString;
+      }
+
+      return base64key;
+    },
+  };
+
+  let retval = {
+    init() {
+      Services.obs.addObserver(this, OnionAuthUtil.topic.clientAuthMissing);
+      Services.obs.addObserver(this, OnionAuthUtil.topic.clientAuthIncorrect);
+    },
+
+    uninit() {
+      Services.obs.removeObserver(this, OnionAuthUtil.topic.clientAuthMissing);
+      Services.obs.removeObserver(this, OnionAuthUtil.topic.clientAuthIncorrect);
+    },
+
+    // aSubject is the DOM Window or browser where the prompt should be shown.
+    // aData contains the .onion name.
+    observe(aSubject, aTopic, aData) {
+      if ((aTopic != OnionAuthUtil.topic.clientAuthMissing) &&
+          (aTopic != OnionAuthUtil.topic.clientAuthIncorrect)) {
+        return;
+      }
+
+      let browser;
+      if (aSubject instanceof Ci.nsIDOMWindow) {
+        let contentWindow = aSubject.QueryInterface(Ci.nsIDOMWindow);
+        browser = contentWindow.docShell.chromeEventHandler;
+      } else {
+        browser = aSubject.QueryInterface(Ci.nsIBrowser);
+      }
+
+      if (!gBrowser.browsers.some(aBrowser => aBrowser == browser)) {
+        return; // This window does not contain the subject browser; ignore.
+      }
+
+      let failedURI = browser.currentURI;
+      let authPrompt = new OnionServicesAuthPrompt(browser, failedURI,
+                                                   aTopic, aData);
+      authPrompt.show(undefined);
+    }
+  };
+
+  return retval;
+})(); /* OnionAuthPrompt */
+
+
+Object.defineProperty(this, "OnionAuthPrompt", {
+  value: OnionAuthPrompt,
+  enumerable: true,
+  writable: false
+});
diff --git a/browser/components/onionservices/content/authUtil.jsm b/browser/components/onionservices/content/authUtil.jsm
new file mode 100644
index 000000000000..c9d83774da1f
--- /dev/null
+++ b/browser/components/onionservices/content/authUtil.jsm
@@ -0,0 +1,47 @@
+// Copyright (c) 2020, The Tor Project, Inc.
+
+"use strict";
+
+var EXPORTED_SYMBOLS = [
+  "OnionAuthUtil",
+];
+
+var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+const OnionAuthUtil = {
+  topic: {
+    clientAuthMissing: "tor-onion-services-clientauth-missing",
+    clientAuthIncorrect: "tor-onion-services-clientauth-incorrect",
+  },
+  message: {
+    authPromptCanceled: "Tor:OnionServicesAuthPromptCanceled",
+  },
+  domid: {
+    anchor: "tor-clientauth-notification-icon",
+    notification: "tor-clientauth",
+    description: "tor-clientauth-notification-desc",
+    learnMore: "tor-clientauth-notification-learnmore",
+    onionNameSpan: "tor-clientauth-notification-onionname",
+    keyElement: "tor-clientauth-notification-key",
+    warningElement: "tor-clientauth-warning",
+    checkboxElement: "tor-clientauth-persistkey-checkbox",
+  },
+
+  addCancelMessageListener(aTabContent, aDocShell) {
+    aTabContent.addMessageListener(this.message.authPromptCanceled,
+                                   (aMessage) => {
+      // Upon cancellation of the client authentication prompt, display
+      // the appropriate error page. When calling the docShell
+      // displayLoadError() function, we pass undefined for the failed
+      // channel so that displayLoadError() can determine that it should
+      // not display the client authentication prompt a second time.
+      let failedURI = Services.io.newURI(aMessage.data.failedURI);
+      let reasonForPrompt = aMessage.data.reasonForPrompt;
+      let errorCode =
+          (reasonForPrompt === this.topic.clientAuthMissing) ?
+          Cr.NS_ERROR_TOR_ONION_SVC_MISSING_CLIENT_AUTH :
+          Cr.NS_ERROR_TOR_ONION_SVC_BAD_CLIENT_AUTH;
+      aDocShell.displayLoadError(errorCode, failedURI, undefined, undefined);
+    });
+  },
+};
diff --git a/browser/components/onionservices/content/netError/browser.svg b/browser/components/onionservices/content/netError/browser.svg
new file mode 100644
index 000000000000..b4c433b37bbb
--- /dev/null
+++ b/browser/components/onionservices/content/netError/browser.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="72" height="65" viewBox="0 0 72 65">
+  <path fill="context-fill" fill-opacity="context-fill-opacity" d="M0.0 0.0C0.0 0.0 0.0 65.0 0.0 65.0C0.0 65.0 72.0 65.0 72.0 65.0C72.0 65.0 72.0 0.0 72.0 0.0C72.0 0.0 52.9019692 0.0 52.9019692 0.0C52.9019692 0.0 0.0 0.0 0.0 0.0C0.0 0.0 0.0 0.0 0.0 0.0M65.0 58.0C65.0 58.0 6.0 58.0 6.0 58.0C6.0 58.0 6.0 25.0 6.0 25.0C6.0 25.0 65.0 25.0 65.0 25.0C65.0 25.0 65.0 58.0 65.0 58.0C65.0 58.0 65.0 58.0 65.0 58.0M6.0 10.0C6.0 10.0 10.0 10.0 10.0 10.0C10.0 10.0 10.0 14.0 10.0 14.0C10.0 14.0 6.0 14.0 6.0 14.0C6.0 14.0 6.0 10.0 6.0 10.0C6.0 10.0 6.0 10.0 6.0 10.0M14.0 10.0C14.0 10.0 18.0 10.0 18.0 10.0C18.0 10.0 18.0 14.0 18.0 14.0C18.0 14.0 14.0 14.0 14.0 14.0C14.0 14.0 14.0 10.0 14.0 10.0C14.0 10.0 14.0 10.0 14.0 10.0M22.0 10.0C22.0 10.0 26.0 10.0 26.0 10.0C26.0 10.0 26.0 14.0 26.0 14.0C26.0 14.0 22.0 14.0 22.0 14.0C22.0 14.0 22.0 10.0 22.0 10.0C22.0 10.0 22.0 10.0 22.0 10.0" />
+</svg>
diff --git a/browser/components/onionservices/content/netError/network.svg b/browser/components/onionservices/content/netError/network.svg
new file mode 100644
index 000000000000..808c53dedd09
--- /dev/null
+++ b/browser/components/onionservices/content/netError/network.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="72" height="54" viewBox="0 0 72 54">
+  <path fill="context-fill" fill-opacity="context-fill-opacity" d="M14.0487805 54.0C6.28990244 54.0 0.0 47.3306322 0.0 39.1034585C0.0 32.0105634 4.68716488 26.0867675 10.9481707 24.585103C10.6902 23.574652 10.5365854 22.5107596 10.5365854 21.4138156C10.5365854 14.7292347 15.6471278 9.3103384 21.9512195 9.3103384C24.8076351 9.3103384 27.4126741 10.4393194 29.4146341 12.2780088C32.1344254 5.0777841 38.77452 0.0 46.5365854 0.0C56.7201249 0.0 64.9756098 8.7536733 64.9756098 19.5517479C64.9756098 20.7691677 64.8471688 21.9453428 64.6463415 23.1013144C69.0576849 26.0679606 72.0 31.2693674 72.0 37.2413909C72.0 46.5256603 64.9510244 54.0 56.195122 54.0C56.195122 54.0 14.0487805 54.0 14.0487805 54.0C14.0487805 54.0 14.0487805 54.0 14.0487805 54.0" />
+</svg>
diff --git a/browser/components/onionservices/content/netError/onionNetError.css b/browser/components/onionservices/content/netError/onionNetError.css
new file mode 100644
index 000000000000..58117ab93223
--- /dev/null
+++ b/browser/components/onionservices/content/netError/onionNetError.css
@@ -0,0 +1,65 @@
+/* Copyright (c) 2020, The Tor Project, Inc. */
+
+:root {
+  --grey-70: #38383d;
+}
+
+#onionErrorDiagramContainer {
+  margin: 60px auto;
+  width: 460px; /* 3 columns @ 140px plus 2 column gaps @ 20px */
+  display: grid;
+  grid-row-gap: 15px;
+  grid-column-gap: 20px;
+  grid-template-columns: 1fr 1fr 1fr;
+}
+
+#onionErrorDiagramContainer > div {
+  margin: auto;
+  position: relative; /* needed to allow overlay of the ok or error icon */
+}
+
+.onionErrorImage {
+  width: 72px;
+  height: 72px;
+  background-position: center;
+  background-repeat: no-repeat;
+  -moz-context-properties: fill;
+  fill: var(--grey-70);
+}
+
+#onionErrorBrowserImage {
+  background-image: url("browser.svg");
+}
+
+#onionErrorNetworkImage {
+  background-image: url("network.svg");
+}
+
+#onionErrorOnionSiteImage {
+  background-image: url("onionsite.svg");
+}
+
+/* rules to support overlay of the ok or error icon */
+.onionErrorImage[status]::after {
+  content: " ";
+  position: absolute;
+  left: -18px;
+  top: 18px;
+  width: 36px;
+  height: 36px;
+  -moz-context-properties: fill;
+  fill: var(--in-content-page-background);
+  background-color: var(--grey-70);
+  background-repeat: no-repeat;
+  background-position: center;
+  border: 3px solid var(--in-content-page-background);
+  border-radius: 50%;
+}
+
+.onionErrorImage[status="ok"]::after {
+  background-image: url("chrome://global/skin/icons/check.svg");
+}
+
+.onionErrorImage[status="error"]::after {
+  background-image: url("chrome://browser/skin/stop.svg");
+}
diff --git a/browser/components/onionservices/content/netError/onionNetError.js b/browser/components/onionservices/content/netError/onionNetError.js
new file mode 100644
index 000000000000..8fabb3f38eb7
--- /dev/null
+++ b/browser/components/onionservices/content/netError/onionNetError.js
@@ -0,0 +1,244 @@
+// Copyright (c) 2020, The Tor Project, Inc.
+
+"use strict";
+
+/* eslint-env mozilla/frame-script */
+
+var OnionServicesAboutNetError = {
+  _selector: {
+    header: ".title-text",
+    longDesc: "#errorLongDesc",
+    learnMoreContainer: "#learnMoreContainer",
+    learnMoreLink: "#learnMoreLink",
+    contentContainer: "#errorLongContent",
+    tryAgainButtonContainer: "#netErrorButtonContainer",
+  },
+  _status: {
+    ok: "ok",
+    error: "error",
+  },
+
+  _diagramInfoMap: undefined,
+
+  // Public functions (called from outside this file).
+  //
+  // This initPage() function may need to be updated if the structure of
+  // browser/base/content/aboutNetError.xhtml changes. Specifically, it
+  // references the following elements:
+  //   query string parameter e
+  //   class title-text
+  //   id errorLongDesc
+  //   id learnMoreContainer
+  //   id learnMoreLink
+  //   id errorLongContent
+  initPage(aDoc) {
+    const searchParams = new URLSearchParams(aDoc.documentURI.split("?")[1]);
+    const err = searchParams.get("e");
+
+    const errPrefix = "onionServices.";
+    const errName = err.substring(errPrefix.length);
+
+    this._strings = RPMGetTorStrings();
+
+    const stringsObj = this._strings[errName];
+    if (!stringsObj) {
+      return;
+    }
+
+    this._insertStylesheet(aDoc);
+
+    const pageTitle = stringsObj.pageTitle;
+    const header = stringsObj.header;
+    const longDescription = stringsObj.longDescription; // optional
+    const learnMoreURL = stringsObj.learnMoreURL;
+
+    if (pageTitle) {
+      aDoc.title = pageTitle;
+    }
+
+    if (header) {
+      const headerElem = aDoc.querySelector(this._selector.header);
+      if (headerElem) {
+        headerElem.textContent = header;
+      }
+    }
+
+    const ld = aDoc.querySelector(this._selector.longDesc);
+    if (ld) {
+      if (longDescription) {
+        const hexErr = this._hexErrorFromName(errName);
+        ld.textContent = longDescription.replace("%S", hexErr);
+      } else {
+        // This onion service error does not have a long description. Since
+        // it is set to a generic error string by the code in
+        // browser/base/content/aboutNetError.js, hide it here.
+        ld.style.display = "none";
+      }
+    }
+
+    if (learnMoreURL) {
+      const lmContainer = aDoc.querySelector(this._selector.learnMoreContainer);
+      if (lmContainer) {
+        lmContainer.style.display = "block";
+      }
+      const lmLink = lmContainer.querySelector(this._selector.learnMoreLink);
+      if (lmLink) {
+        lmLink.setAttribute("href", learnMoreURL);
+      }
+    }
+
+    // Remove the "Try Again" button if the user made a typo in the .onion
+    // address since it is not useful in that case.
+    if (errName === "badAddress") {
+      const tryAgainButton = aDoc.querySelector(
+        this._selector.tryAgainButtonContainer
+      );
+      if (tryAgainButton) {
+        tryAgainButton.style.display = "none";
+      }
+    }
+
+    this._insertDiagram(aDoc, errName);
+  }, // initPage()
+
+  _insertStylesheet(aDoc) {
+    const url =
+      "chrome://browser/content/onionservices/netError/onionNetError.css";
+    let linkElem = aDoc.createElement("link");
+    linkElem.rel = "stylesheet";
+    linkElem.href = url;
+    linkElem.type = "text/css";
+    aDoc.head.appendChild(linkElem);
+  },
+
+  _insertDiagram(aDoc, aErrorName) {
+    // The onion error diagram consists of a grid of div elements.
+    // The first row contains three images (Browser, Network, Onionsite) and
+    // the second row contains labels for the images that are in the first row.
+    // The _diagramInfoMap describes for each type of onion service error
+    // whether a small ok or error status icon is overlaid on top of the main
+    // Browser/Network/Onionsite images.
+    if (!this._diagramInfoMap) {
+      this._diagramInfoMap = new Map();
+      this._diagramInfoMap.set("descNotFound", {
+        browser: this._status.ok,
+        network: this._status.ok,
+        onionSite: this._status.error,
+      });
+      this._diagramInfoMap.set("descInvalid", {
+        browser: this._status.ok,
+        network: this._status.error,
+      });
+      this._diagramInfoMap.set("introFailed", {
+        browser: this._status.ok,
+        network: this._status.error,
+      });
+      this._diagramInfoMap.set("rendezvousFailed", {
+        browser: this._status.ok,
+        network: this._status.error,
+      });
+      this._diagramInfoMap.set("clientAuthMissing", {
+        browser: this._status.error,
+      });
+      this._diagramInfoMap.set("clientAuthIncorrect", {
+        browser: this._status.error,
+      });
+      this._diagramInfoMap.set("badAddress", {
+        browser: this._status.error,
+      });
+      this._diagramInfoMap.set("introTimedOut", {
+        browser: this._status.ok,
+        network: this._status.error,
+      });
+    }
+
+    const diagramInfo = this._diagramInfoMap.get(aErrorName);
+
+    const container = this._createDiv(aDoc, "onionErrorDiagramContainer");
+    const imageClass = "onionErrorImage";
+
+    const browserImage = this._createDiv(
+      aDoc,
+      "onionErrorBrowserImage",
+      imageClass,
+      container
+    );
+    if (diagramInfo && diagramInfo.browser) {
+      browserImage.setAttribute("status", diagramInfo.browser);
+    }
+
+    const networkImage = this._createDiv(
+      aDoc,
+      "onionErrorNetworkImage",
+      imageClass,
+      container
+    );
+    if (diagramInfo && diagramInfo.network) {
+      networkImage.setAttribute("status", diagramInfo.network);
+    }
+
+    const onionSiteImage = this._createDiv(
+      aDoc,
+      "onionErrorOnionSiteImage",
+      imageClass,
+      container
+    );
+    if (diagramInfo && diagramInfo.onionSite) {
+      onionSiteImage.setAttribute("status", diagramInfo.onionSite);
+    }
+
+    let labelDiv = this._createDiv(aDoc, undefined, undefined, container);
+    labelDiv.textContent = this._strings.errorPage.browser;
+    labelDiv = this._createDiv(aDoc, undefined, undefined, container);
+    labelDiv.textContent = this._strings.errorPage.network;
+    labelDiv = this._createDiv(aDoc, undefined, undefined, container);
+    labelDiv.textContent = this._strings.errorPage.onionSite;
+
+    const contentContainer = aDoc.querySelector(
+      this._selector.contentContainer
+    );
+    if (contentContainer) {
+      contentContainer.insertBefore(container, contentContainer.firstChild);
+    }
+  }, // _insertDiagram()
+
+  _createDiv(aDoc, aID, aClass, aParentElem) {
+    const div = aDoc.createElement("div");
+    if (aID) {
+      div.id = aID;
+    }
+    if (aClass) {
+      div.setAttribute("class", aClass);
+    }
+    if (aParentElem) {
+      aParentElem.appendChild(div);
+    }
+
+    return div;
+  },
+
+  _hexErrorFromName(aErrorName) {
+    // We do not have access to the original Tor SOCKS error code here, so
+    // perform a reverse mapping from the error name.
+    switch (aErrorName) {
+      case "descNotFound":
+        return "0xF0";
+      case "descInvalid":
+        return "0xF1";
+      case "introFailed":
+        return "0xF2";
+      case "rendezvousFailed":
+        return "0xF3";
+      case "clientAuthMissing":
+        return "0xF4";
+      case "clientAuthIncorrect":
+        return "0xF5";
+      case "badAddress":
+        return "0xF6";
+      case "introTimedOut":
+        return "0xF7";
+    }
+
+    return "";
+  },
+};
diff --git a/browser/components/onionservices/content/netError/onionsite.svg b/browser/components/onionservices/content/netError/onionsite.svg
new file mode 100644
index 000000000000..1f2777e6acc7
--- /dev/null
+++ b/browser/components/onionservices/content/netError/onionsite.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="70" height="63" viewBox="0 0 70 63">
+  <g fill="context-fill" fill-opacity="context-fill-opacity">
+    <path d="M64.0 2.0C64.0 2.0 4.0 2.0 4.0 2.0C2.8954305 2.0 2.0 2.81148389 2.0 3.8125C2.0 3.8125 2.0 58.1875 2.0 58.1875C2.0 59.1885161 2.8954305 60.0 4.0 60.0C4.0 60.0 36.0 60.0 36.0 60.0C36.0 60.0 36.0 56.375 36.0 56.375C36.0 56.375 6.0 56.375 6.0 56.375C6.0 56.375 6.0 41.875 6.0 41.875C6.0 41.875 38.0 41.875 38.0 41.875C38.0 41.875 38.0 38.25 38.0 38.25C38.0 38.25 6.0 38.25 6.0 38.25C6.0 38.25 6.0 23.75 6.0 23.75C6.0 23.75 62.0 23.75 62.0 23.75C62.0 23.75 62.0 36.4375 62.0 36.4375C62.0 36.4375 66.0 36.4375 66.0 36.4375C66.0 36.4375 66.0 3.8125 66.0 3.8125C66.0 2.81148389 65.1045695 2.0 64.0 2.0C64.0 2.0 64.0 2.0 64.0 2.0M62.0 20.125C62.0 20.125 6.0 20.125 6.0 20.125C6.0 20.125 6.0 5.625 6.0 5.625C6.0 5.625 62.0 5.625 62.0 5.625C62.0 5.625 62.0 20.125 62.0 20.125C62.0 20.125 62.0 20.125 62.0 20.125" />
+    <path d="M24.0 47.0C24.0 47.0 24.0 51.0 24.0 51.0C24.0 51.0 20.0 51.0 20.0 51.0C20.0 51.0 20.0 47.0 20.0 47.0C20.0 47.0 24.0 47.0 24.0 47.0C24.0 47.0 24.0 47.0 24.0 47.0M16.0 47.0C16.0 47.0 16.0 51.0 16.0 51.0C16.0 51.0 12.0 51.0 12.0 51.0C12.0 51.0 12.0 47.0 12.0 47.0C12.0 47.0 16.0 47.0 16.0 47.0C16.0 47.0 16.0 47.0 16.0 47.0M56.0 29.0C56.0 29.0 56.0 33.0 56.0 33.0C56.0 33.0 52.0 33.0 52.0 33.0C52.0 33.0 52.0 29.0 52.0 29.0C52.0 29.0 56.0 29.0 56.0 29.0C56.0 29.0 56.0 29.0 56.0 29.0M48.0 29.0C48.0 29.0 48.0 33.0 48.0 33.0C48.0 33.0 12.0 33.0 12.0 33.0C12.0 33.0 12.0 29.0 12.0 29.0C12.0 29.0 48.0 29.0 48.0 29.0C48.0 29.0 48.0 29.0 48.0 29.0M22.0 11.0C22.0 11.0 22.0 15.0 22.0 15.0C22.0 15.0 10.0 15.0 10.0 15.0C10.0 15.0 10.0 11.0 10.0 11.0C10.0 11.0 22.0 11.0 22.0 11.0C22.0 11.0 22.0 11.0 22.0 11.0M70.0 0.0C70.0 0.0 70.0 36.5 70.0 36.5C70.0 36.5 65.0 36.5 65.0 36.5C65.0 36.5 65.0 4.5 65.0 4.5C65.0 4.5 5.0 4.5 5.0 4.5C5.0 4.5 5.0 58.5 5.0 58.5C5.0 58.5 36.0 58.5 36.0 58.5C36.0 58
 .5 36.0 63.0 36.0 63.0C36.0 63.0 0.0 63.0 0.0 63.0C0.0 63.0 0.0 0.0 0.0 0.0C0.0 0.0 70.0 0.0 70.0 0.0C70.0 0.0 70.0 0.0 70.0 0.0M32.0 47.0C32.0 47.0 32.0 51.0 32.0 51.0C32.0 51.0 28.0 51.0 28.0 51.0C28.0 51.0 28.0 47.0 28.0 47.0C28.0 47.0 32.0 47.0 32.0 47.0C32.0 47.0 32.0 47.0 32.0 47.0M54.0 11.0C54.0 11.0 54.0 15.0 54.0 15.0C54.0 15.0 50.0 15.0 50.0 15.0C50.0 15.0 50.0 11.0 50.0 11.0C50.0 11.0 54.0 11.0 54.0 11.0C54.0 11.0 54.0 11.0 54.0 11.0M46.0 11.0C46.0 11.0 46.0 15.0 46.0 15.0C46.0 15.0 42.0 15.0 42.0 15.0C42.0 15.0 42.0 11.0 42.0 11.0C42.0 11.0 46.0 11.0 46.0 11.0C46.0 11.0 46.0 11.0 46.0 11.0M38.0 11.0C38.0 11.0 38.0 15.0 38.0 15.0C38.0 15.0 34.0 15.0 34.0 15.0C34.0 15.0 34.0 11.0 34.0 11.0C34.0 11.0 38.0 11.0 38.0 11.0C38.0 11.0 38.0 11.0 38.0 11.0M30.0 11.0C30.0 11.0 30.0 15.0 30.0 15.0C30.0 15.0 26.0 15.0 26.0 15.0C26.0 15.0 26.0 11.0 26.0 11.0C26.0 11.0 30.0 11.0 30.0 11.0C30.0 11.0 30.0 11.0 30.0 11.0" />
+    <path d="M61.0 46.0C61.0 46.0 59.0 46.0 59.0 46.0C59.0 46.0 59.0 40.0 59.0 40.0C59.0 38.8954305 58.1045695 38.0 57.0 38.0C57.0 38.0 49.0 38.0 49.0 38.0C47.8954305 38.0 47.0 38.8954305 47.0 40.0C47.0 40.0 47.0 46.0 47.0 46.0C47.0 46.0 45.0 46.0 45.0 46.0C43.8954305 46.0 43.0 46.8954305 43.0 48.0C43.0 48.0 43.0 60.0 43.0 60.0C43.0 61.1045695 43.8954305 62.0 45.0 62.0C45.0 62.0 61.0 62.0 61.0 62.0C62.1045695 62.0 63.0 61.1045695 63.0 60.0C63.0 60.0 63.0 48.0 63.0 48.0C63.0 46.8954305 62.1045695 46.0 61.0 46.0C61.0 46.0 61.0 46.0 61.0 46.0M51.0 42.0C51.0 42.0 55.0 42.0 55.0 42.0C55.0 42.0 55.0 46.0 55.0 46.0C55.0 46.0 51.0 46.0 51.0 46.0C51.0 46.0 51.0 42.0 51.0 42.0C51.0 42.0 51.0 42.0 51.0 42.0M59.0 58.0C59.0 58.0 47.0 58.0 47.0 58.0C47.0 58.0 47.0 50.0 47.0 50.0C47.0 50.0 59.0 50.0 59.0 50.0C59.0 50.0 59.0 58.0 59.0 58.0C59.0 58.0 59.0 58.0 59.0 58.0" />
+  </g>
+</svg>
diff --git a/browser/components/onionservices/content/onionservices.css b/browser/components/onionservices/content/onionservices.css
new file mode 100644
index 000000000000..e2621ec8266d
--- /dev/null
+++ b/browser/components/onionservices/content/onionservices.css
@@ -0,0 +1,69 @@
+/* Copyright (c) 2020, The Tor Project, Inc. */
+
+ at namespace html url("http://www.w3.org/1999/xhtml");
+
+html|*#tor-clientauth-notification-onionname {
+  font-weight: bold;
+}
+
+html|*#tor-clientauth-notification-key {
+  box-sizing: border-box;
+  width: 100%;
+  margin-top: 15px;
+  padding: 6px;
+}
+
+/* Start of rules adapted from
+ * browser/components/newtab/css/activity-stream-mac.css (linux and windows
+ * use the same rules).
+ */
+html|*#tor-clientauth-notification-key.invalid {
+  border: 1px solid #D70022;
+  box-shadow: 0 0 0 1px #D70022, 0 0 0 4px rgba(215, 0, 34, 0.3);
+}
+
+html|*#tor-clientauth-warning {
+  display: inline-block;
+  animation: fade-up-tt 450ms;
+  background: #D70022;
+  border-radius: 2px;
+  color: #FFF;
+  inset-inline-start: 3px;
+  padding: 5px 12px;
+  position: relative;
+  top: 6px;
+  z-index: 1;
+}
+
+html|*#tor-clientauth-warning[hidden] {
+  display: none;
+}
+
+html|*#tor-clientauth-warning::before {
+  background: #D70022;
+  bottom: -8px;
+  content: '.';
+  height: 16px;
+  inset-inline-start: 12px;
+  position: absolute;
+  text-indent: -999px;
+  top: -7px;
+  transform: rotate(45deg);
+  white-space: nowrap;
+  width: 16px;
+  z-index: -1;
+}
+
+ at keyframes fade-up-tt {
+  0% {
+    opacity: 0;
+    transform: translateY(15px);
+  }
+  100% {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+/* End of rules adapted from
+ * browser/components/newtab/css/activity-stream-mac.css
+ */
diff --git a/browser/components/onionservices/content/savedKeysDialog.js b/browser/components/onionservices/content/savedKeysDialog.js
new file mode 100644
index 000000000000..b1376bbabe85
--- /dev/null
+++ b/browser/components/onionservices/content/savedKeysDialog.js
@@ -0,0 +1,259 @@
+// Copyright (c) 2020, The Tor Project, Inc.
+
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+  this,
+  "TorStrings",
+  "resource:///modules/TorStrings.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+  this,
+  "controller",
+  "resource://torbutton/modules/tor-control-port.js"
+);
+
+var gOnionServicesSavedKeysDialog = {
+  selector: {
+    dialog: "#onionservices-savedkeys-dialog",
+    intro: "#onionservices-savedkeys-intro",
+    tree: "#onionservices-savedkeys-tree",
+    onionSiteCol: "#onionservices-savedkeys-siteCol",
+    onionKeyCol: "#onionservices-savedkeys-keyCol",
+    errorIcon: "#onionservices-savedkeys-errorIcon",
+    errorMessage: "#onionservices-savedkeys-errorMessage",
+    removeButton: "#onionservices-savedkeys-remove",
+    removeAllButton: "#onionservices-savedkeys-removeall",
+  },
+
+  _tree: undefined,
+  _isBusy: false, // true when loading data, deleting a key, etc.
+
+  // Public functions (called from outside this file).
+  async deleteSelectedKeys() {
+    this._setBusyState(true);
+
+    const indexesToDelete = [];
+    const count = this._tree.view.selection.getRangeCount();
+    for (let i = 0; i < count; ++i) {
+      const minObj = {};
+      const maxObj = {};
+      this._tree.view.selection.getRangeAt(i, minObj, maxObj);
+      for (let idx = minObj.value; idx <= maxObj.value; ++idx) {
+        indexesToDelete.push(idx);
+      }
+    }
+
+    if (indexesToDelete.length > 0) {
+      const controllerFailureMsg =
+        TorStrings.onionServices.authPreferences.failedToRemoveKey;
+      try {
+        const torController = controller(aError => {
+          this._showError(controllerFailureMsg);
+        });
+
+        // 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]);
+        }
+      } catch (e) {
+        if (e.torMessage) {
+          this._showError(e.torMessage);
+        } else {
+          this._showError(controllerFailureMsg);
+        }
+      }
+    }
+
+    this._setBusyState(false);
+  },
+
+  async deleteAllKeys() {
+    this._tree.view.selection.selectAll();
+    await this.deleteSelectedKeys();
+  },
+
+  updateButtonsState() {
+    const haveSelection = this._tree.view.selection.getRangeCount() > 0;
+    const dialog = document.querySelector(this.selector.dialog);
+    const removeSelectedBtn = dialog.querySelector(this.selector.removeButton);
+    removeSelectedBtn.disabled = this._isBusy || !haveSelection;
+    const removeAllBtn = dialog.querySelector(this.selector.removeAllButton);
+    removeAllBtn.disabled = this._isBusy || this.rowCount === 0;
+  },
+
+  // Private functions.
+  _onLoad() {
+    document.mozSubdialogReady = this._init();
+  },
+
+  async _init() {
+    await this._populateXUL();
+
+    window.addEventListener("keypress", this._onWindowKeyPress.bind(this));
+
+    // We don't use await here because we want _loadSavedKeys() to run
+    // in the background and not block loading of this dialog.
+    this._loadSavedKeys();
+  },
+
+  async _populateXUL() {
+    const dialog = document.querySelector(this.selector.dialog);
+    const authPrefStrings = TorStrings.onionServices.authPreferences;
+    dialog.setAttribute("title", authPrefStrings.dialogTitle);
+
+    let elem = dialog.querySelector(this.selector.intro);
+    elem.textContent = authPrefStrings.dialogIntro;
+
+    elem = dialog.querySelector(this.selector.onionSiteCol);
+    elem.setAttribute("label", authPrefStrings.onionSite);
+
+    elem = dialog.querySelector(this.selector.onionKeyCol);
+    elem.setAttribute("label", authPrefStrings.onionKey);
+
+    elem = dialog.querySelector(this.selector.removeButton);
+    elem.setAttribute("label", authPrefStrings.remove);
+
+    elem = dialog.querySelector(this.selector.removeAllButton);
+    elem.setAttribute("label", authPrefStrings.removeAll);
+
+    this._tree = dialog.querySelector(this.selector.tree);
+  },
+
+  async _loadSavedKeys() {
+    const controllerFailureMsg =
+      TorStrings.onionServices.authPreferences.failedToGetKeys;
+    this._setBusyState(true);
+
+    try {
+      this._tree.view = this;
+
+      const torController = controller(aError => {
+        this._showError(controllerFailureMsg);
+      });
+
+      const keyInfoList = await torController.onionAuthViewKeys();
+      if (keyInfoList) {
+        // Filter out temporary keys.
+        this._keyInfoList = keyInfoList.filter(aKeyInfo => {
+          if (!aKeyInfo.Flags) {
+            return false;
+          }
+
+          const flags = aKeyInfo.Flags.split(",");
+          return flags.includes("Permanent");
+        });
+
+        // Sort by the .onion address.
+        this._keyInfoList.sort((aObj1, aObj2) => {
+          const hsAddr1 = aObj1.hsAddress.toLowerCase();
+          const hsAddr2 = aObj2.hsAddress.toLowerCase();
+          if (hsAddr1 < hsAddr2) {
+            return -1;
+          }
+          return hsAddr1 > hsAddr2 ? 1 : 0;
+        });
+      }
+
+      // Render the tree content.
+      this._tree.rowCountChanged(0, this.rowCount);
+    } catch (e) {
+      if (e.torMessage) {
+        this._showError(e.torMessage);
+      } else {
+        this._showError(controllerFailureMsg);
+      }
+    }
+
+    this._setBusyState(false);
+  },
+
+  // This method may throw; callers should catch errors.
+  async _deleteOneKey(aTorController, aIndex) {
+    const keyInfoObj = this._keyInfoList[aIndex];
+    await aTorController.onionAuthRemove(keyInfoObj.hsAddress);
+    this._tree.view.selection.clearRange(aIndex, aIndex);
+    this._keyInfoList.splice(aIndex, 1);
+    this._tree.rowCountChanged(aIndex + 1, -1);
+  },
+
+  _setBusyState(aIsBusy) {
+    this._isBusy = aIsBusy;
+    this.updateButtonsState();
+  },
+
+  _onWindowKeyPress(event) {
+    if (event.keyCode === KeyEvent.DOM_VK_ESCAPE) {
+      window.close();
+    } else if (event.keyCode === KeyEvent.DOM_VK_DELETE) {
+      this.deleteSelectedKeys();
+    }
+  },
+
+  _showError(aMessage) {
+    const dialog = document.querySelector(this.selector.dialog);
+    const errorIcon = dialog.querySelector(this.selector.errorIcon);
+    errorIcon.style.visibility = aMessage ? "visible" : "hidden";
+    const errorDesc = dialog.querySelector(this.selector.errorMessage);
+    errorDesc.textContent = aMessage ? aMessage : "";
+  },
+
+  // XUL tree widget view implementation.
+  get rowCount() {
+    return this._keyInfoList ? this._keyInfoList.length : 0;
+  },
+
+  getCellText(aRow, aCol) {
+    let val = "";
+    if (this._keyInfoList && aRow < this._keyInfoList.length) {
+      const keyInfo = this._keyInfoList[aRow];
+      if (aCol.id.endsWith("-siteCol")) {
+        val = keyInfo.hsAddress;
+      } else if (aCol.id.endsWith("-keyCol")) {
+        val = keyInfo.typeAndKey;
+        // Omit keyType because it is always "x25519".
+        const idx = val.indexOf(":");
+        if (idx > 0) {
+          val = val.substring(idx + 1);
+        }
+      }
+    }
+
+    return val;
+  },
+
+  isSeparator(index) {
+    return false;
+  },
+
+  isSorted() {
+    return false;
+  },
+
+  isContainer(index) {
+    return false;
+  },
+
+  setTree(tree) {},
+
+  getImageSrc(row, column) {},
+
+  getCellValue(row, column) {},
+
+  cycleHeader(column) {},
+
+  getRowProperties(row) {
+    return "";
+  },
+
+  getColumnProperties(column) {
+    return "";
+  },
+
+  getCellProperties(row, column) {
+    return "";
+  },
+};
+
+window.addEventListener("load", () => gOnionServicesSavedKeysDialog._onLoad());
diff --git a/browser/components/onionservices/content/savedKeysDialog.xhtml b/browser/components/onionservices/content/savedKeysDialog.xhtml
new file mode 100644
index 000000000000..3db9bb05ea82
--- /dev/null
+++ b/browser/components/onionservices/content/savedKeysDialog.xhtml
@@ -0,0 +1,42 @@
+<?xml version="1.0"?>
+<!-- Copyright (c) 2020, The Tor Project, Inc. -->
+
+<?xml-stylesheet href="chrome://global/skin/" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/preferences/preferences.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/content/onionservices/authPreferences.css" type="text/css"?>
+
+<window id="onionservices-savedkeys-dialog"
+    windowtype="OnionServices:SavedKeys"
+    xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+    style="width: 45em;">
+
+  <script src="chrome://browser/content/onionservices/savedKeysDialog.js"/>
+
+  <vbox id="onionservices-savedkeys" class="contentPane" flex="1">
+    <label id="onionservices-savedkeys-intro"
+           control="onionservices-savedkeys-tree"/>
+    <separator class="thin"/>
+    <tree id="onionservices-savedkeys-tree" flex="1" hidecolumnpicker="true"
+          width="750"
+          style="height: 20em;"
+          onselect="gOnionServicesSavedKeysDialog.updateButtonsState();">
+      <treecols>
+        <treecol id="onionservices-savedkeys-siteCol" flex="1" persist="width"/>
+        <splitter class="tree-splitter"/>
+        <treecol id="onionservices-savedkeys-keyCol" flex="1" persist="width"/>
+      </treecols>
+      <treechildren/>
+    </tree>
+    <hbox id="onionservices-savedkeys-errorContainer" align="baseline" flex="1">
+      <image id="onionservices-savedkeys-errorIcon"/>
+      <description id="onionservices-savedkeys-errorMessage" flex="1"/>
+    </hbox>
+    <separator class="thin"/>
+    <hbox id="onionservices-savedkeys-buttons">
+      <button id="onionservices-savedkeys-remove" disabled="true"
+              oncommand="gOnionServicesSavedKeysDialog.deleteSelectedKeys();"/>
+      <button id="onionservices-savedkeys-removeall"
+              oncommand="gOnionServicesSavedKeysDialog.deleteAllKeys();"/>
+    </hbox>
+  </vbox>
+</window>
diff --git a/browser/components/onionservices/jar.mn b/browser/components/onionservices/jar.mn
new file mode 100644
index 000000000000..9d6ce88d1841
--- /dev/null
+++ b/browser/components/onionservices/jar.mn
@@ -0,0 +1,9 @@
+browser.jar:
+    content/browser/onionservices/authPreferences.css              (content/authPreferences.css)
+    content/browser/onionservices/authPreferences.js               (content/authPreferences.js)
+    content/browser/onionservices/authPrompt.js                    (content/authPrompt.js)
+    content/browser/onionservices/authUtil.jsm                     (content/authUtil.jsm)
+    content/browser/onionservices/netError/                        (content/netError/*)
+    content/browser/onionservices/onionservices.css                (content/onionservices.css)
+    content/browser/onionservices/savedKeysDialog.js               (content/savedKeysDialog.js)
+    content/browser/onionservices/savedKeysDialog.xhtml            (content/savedKeysDialog.xhtml)
diff --git a/browser/components/onionservices/moz.build b/browser/components/onionservices/moz.build
new file mode 100644
index 000000000000..7e103239c8d6
--- /dev/null
+++ b/browser/components/onionservices/moz.build
@@ -0,0 +1 @@
+JAR_MANIFESTS += ['jar.mn']
diff --git a/browser/components/preferences/preferences.xhtml b/browser/components/preferences/preferences.xhtml
index 2a99400bfb70..b09808e83c45 100644
--- a/browser/components/preferences/preferences.xhtml
+++ b/browser/components/preferences/preferences.xhtml
@@ -12,6 +12,7 @@
 <?xml-stylesheet href="chrome://browser/skin/preferences/search.css"?>
 <?xml-stylesheet href="chrome://browser/skin/preferences/containers.css"?>
 <?xml-stylesheet href="chrome://browser/skin/preferences/privacy.css"?>
+<?xml-stylesheet href="chrome://browser/content/onionservices/authPreferences.css"?>
 <?xml-stylesheet href="chrome://browser/content/securitylevel/securityLevelPreferences.css"?>
 <?xml-stylesheet href="chrome://browser/content/torpreferences/torPreferences.css"?>
 
diff --git a/browser/components/preferences/privacy.inc.xhtml b/browser/components/preferences/privacy.inc.xhtml
index f36145ea80d4..eb7587afa0e1 100644
--- a/browser/components/preferences/privacy.inc.xhtml
+++ b/browser/components/preferences/privacy.inc.xhtml
@@ -477,6 +477,8 @@
   <label id="fips-desc" hidden="true" data-l10n-id="forms-master-pw-fips-desc"></label>
 </groupbox>
 
+#include ../onionservices/content/authPreferences.inc.xhtml
+
 <!-- The form autofill section is inserted in to this box
      after the form autofill extension has initialized. -->
 <groupbox id="formAutofillGroupBox"
diff --git a/browser/components/preferences/privacy.js b/browser/components/preferences/privacy.js
index 35e1fda9f96b..41dda96a14de 100644
--- a/browser/components/preferences/privacy.js
+++ b/browser/components/preferences/privacy.js
@@ -77,6 +77,12 @@ XPCOMUtils.defineLazyGetter(this, "AlertsServiceDND", function() {
   }
 });
 
+XPCOMUtils.defineLazyScriptGetter(
+  this,
+  ["OnionServicesAuthPreferences"],
+  "chrome://browser/content/onionservices/authPreferences.js"
+);
+
 // TODO: module import via ChromeUtils.defineModuleGetter
 XPCOMUtils.defineLazyScriptGetter(
   this,
@@ -434,6 +440,7 @@ var gPrivacyPane = {
     this.trackingProtectionReadPrefs();
     this.networkCookieBehaviorReadPrefs();
     this._initTrackingProtectionExtensionControl();
+    OnionServicesAuthPreferences.init();
     this._initSecurityLevel();
 
     Services.telemetry.setEventRecordingEnabled("pwmgr", true);
diff --git a/browser/themes/shared/notification-icons.inc.css b/browser/themes/shared/notification-icons.inc.css
index f17ddae9dc79..979ae9482244 100644
--- a/browser/themes/shared/notification-icons.inc.css
+++ b/browser/themes/shared/notification-icons.inc.css
@@ -117,6 +117,9 @@
   list-style-image: url(chrome://browser/skin/notification-icons/indexedDB.svg);
 }
 
+/* Reuse Firefox's login (key) icon for the Tor onion services auth. prompt */
+.popup-notification-icon[popupid="tor-clientauth"],
+.tor-clientauth-icon,
 .popup-notification-icon[popupid="password"],
 .login-icon {
   list-style-image: url(chrome://browser/skin/login.svg);
diff --git a/docshell/base/nsDocShell.cpp b/docshell/base/nsDocShell.cpp
index 2e0f2e749741..97c06788ad33 100644
--- a/docshell/base/nsDocShell.cpp
+++ b/docshell/base/nsDocShell.cpp
@@ -3536,6 +3536,7 @@ nsDocShell::DisplayLoadError(nsresult aError, nsIURI* aURI,
     }
   } else {
     // Errors requiring simple formatting
+    bool isOnionAuthError = false;
     switch (aError) {
       case NS_ERROR_MALFORMED_URI:
         // URI is malformed
@@ -3618,10 +3619,44 @@ nsDocShell::DisplayLoadError(nsresult aError, nsIURI* aURI,
         // HTTP/2 or HTTP/3 stack detected a protocol error
         error = "networkProtocolError";
         break;
-
+      case NS_ERROR_TOR_ONION_SVC_NOT_FOUND:
+        error = "onionServices.descNotFound";
+        break;
+      case NS_ERROR_TOR_ONION_SVC_IS_INVALID:
+        error = "onionServices.descInvalid";
+        break;
+      case NS_ERROR_TOR_ONION_SVC_INTRO_FAILED:
+        error = "onionServices.introFailed";
+        break;
+      case NS_ERROR_TOR_ONION_SVC_REND_FAILED:
+        error = "onionServices.rendezvousFailed";
+        break;
+      case NS_ERROR_TOR_ONION_SVC_MISSING_CLIENT_AUTH:
+        error = "onionServices.clientAuthMissing";
+        isOnionAuthError = true;
+        break;
+      case NS_ERROR_TOR_ONION_SVC_BAD_CLIENT_AUTH:
+        error = "onionServices.clientAuthIncorrect";
+        isOnionAuthError = true;
+        break;
+      case NS_ERROR_TOR_ONION_SVC_BAD_ADDRESS:
+        error = "onionServices.badAddress";
+        break;
+      case NS_ERROR_TOR_ONION_SVC_INTRO_TIMEDOUT:
+        error = "onionServices.introTimedOut";
+        break;
       default:
         break;
     }
+
+    // The presence of aFailedChannel indicates that we arrived here due to a
+    // failed connection attempt. Note that we will arrive here a second time
+    // if the user cancels the Tor client auth prompt, but in that case we
+    // will not have a failed channel and therefore we will not prompt again.
+    if (isOnionAuthError && aFailedChannel) {
+      // Display about:blank while the Tor client auth prompt is open.
+      errorPage.AssignLiteral("blank");
+    }
   }
 
   // If the HTTPS-Only Mode upgraded this request and the upgrade might have
@@ -3710,6 +3745,20 @@ nsDocShell::DisplayLoadError(nsresult aError, nsIURI* aURI,
     nsAutoString str;
     rv =
         stringBundle->FormatStringFromName(errorDescriptionID, formatStrs, str);
+    if (NS_FAILED(rv)) {
+      // As a fallback, check torbutton.properties for the error string.
+      const char bundleURL[] = "chrome://torbutton/locale/torbutton.properties";
+      nsCOMPtr<nsIStringBundleService> stringBundleService =
+          mozilla::services::GetStringBundleService();
+      if (stringBundleService) {
+        nsCOMPtr<nsIStringBundle> tbStringBundle;
+        if (NS_SUCCEEDED(stringBundleService->CreateBundle(
+                bundleURL, getter_AddRefs(tbStringBundle)))) {
+          rv = tbStringBundle->FormatStringFromName(errorDescriptionID,
+                                                    formatStrs, str);
+        }
+      }
+    }
     NS_ENSURE_SUCCESS(rv, rv);
     messageStr.Assign(str);
   }
@@ -6189,6 +6238,7 @@ nsresult nsDocShell::EndPageLoad(nsIWebProgress* aProgress,
                aStatus == NS_ERROR_NET_INADEQUATE_SECURITY ||
                aStatus == NS_ERROR_NET_HTTP2_SENT_GOAWAY ||
                aStatus == NS_ERROR_NET_HTTP3_PROTOCOL_ERROR ||
+               NS_ERROR_GET_MODULE(aStatus) == NS_ERROR_MODULE_TOR ||
                NS_ERROR_GET_MODULE(aStatus) == NS_ERROR_MODULE_SECURITY) {
       // Errors to be shown for any frame
       DisplayLoadError(aStatus, url, nullptr, aChannel);
@@ -7746,6 +7796,35 @@ nsresult nsDocShell::CreateContentViewer(const nsACString& aContentType,
     FireOnLocationChange(this, aRequest, mCurrentURI, locationFlags);
   }
 
+  // Arrange to show a Tor onion service client authentication prompt if
+  // appropriate.
+  if ((mLoadType == LOAD_ERROR_PAGE) && failedChannel) {
+    nsresult status = NS_OK;
+    if (NS_SUCCEEDED(failedChannel->GetStatus(&status)) &&
+        ((status == NS_ERROR_TOR_ONION_SVC_MISSING_CLIENT_AUTH) ||
+         (status == NS_ERROR_TOR_ONION_SVC_BAD_CLIENT_AUTH))) {
+      nsAutoCString onionHost;
+      failedURI->GetHost(onionHost);
+      const char* topic = (status == NS_ERROR_TOR_ONION_SVC_MISSING_CLIENT_AUTH)
+                              ? "tor-onion-services-clientauth-missing"
+                              : "tor-onion-services-clientauth-incorrect";
+      if (XRE_IsContentProcess()) {
+        nsCOMPtr<nsIBrowserChild> browserChild = GetBrowserChild();
+        if (browserChild) {
+          static_cast<BrowserChild*>(browserChild.get())
+              ->SendShowOnionServicesAuthPrompt(onionHost, nsCString(topic));
+        }
+      } else {
+        nsCOMPtr<nsPIDOMWindowOuter> browserWin = GetWindow();
+        nsCOMPtr<nsIObserverService> obsSvc = services::GetObserverService();
+        if (browserWin && obsSvc) {
+          obsSvc->NotifyObservers(browserWin, topic,
+                                  NS_ConvertUTF8toUTF16(onionHost).get());
+        }
+      }
+    }
+  }
+
   return NS_OK;
 }
 
diff --git a/dom/ipc/BrowserParent.cpp b/dom/ipc/BrowserParent.cpp
index dfd15c1fd17b..7749792cafb4 100644
--- a/dom/ipc/BrowserParent.cpp
+++ b/dom/ipc/BrowserParent.cpp
@@ -3941,6 +3941,27 @@ mozilla::ipc::IPCResult BrowserParent::RecvShowCanvasPermissionPrompt(
   return IPC_OK();
 }
 
+mozilla::ipc::IPCResult BrowserParent::RecvShowOnionServicesAuthPrompt(
+    const nsCString& aOnionName, const nsCString& aTopic) {
+  nsCOMPtr<nsIBrowser> browser =
+      mFrameElement ? mFrameElement->AsBrowser() : nullptr;
+  if (!browser) {
+    // If the tab is being closed, the browser may not be available.
+    // In this case we can ignore the request.
+    return IPC_OK();
+  }
+  nsCOMPtr<nsIObserverService> os = services::GetObserverService();
+  if (!os) {
+    return IPC_FAIL_NO_REASON(this);
+  }
+  nsresult rv = os->NotifyObservers(browser, aTopic.get(),
+                                    NS_ConvertUTF8toUTF16(aOnionName).get());
+  if (NS_FAILED(rv)) {
+    return IPC_FAIL_NO_REASON(this);
+  }
+  return IPC_OK();
+}
+
 mozilla::ipc::IPCResult BrowserParent::RecvVisitURI(nsIURI* aURI,
                                                     nsIURI* aLastVisitedURI,
                                                     const uint32_t& aFlags) {
diff --git a/dom/ipc/BrowserParent.h b/dom/ipc/BrowserParent.h
index 66509194edba..816945504b6c 100644
--- a/dom/ipc/BrowserParent.h
+++ b/dom/ipc/BrowserParent.h
@@ -763,6 +763,9 @@ class BrowserParent final : public PBrowserParent,
   mozilla::ipc::IPCResult RecvShowCanvasPermissionPrompt(
       const nsCString& aOrigin, const bool& aHideDoorHanger);
 
+  mozilla::ipc::IPCResult RecvShowOnionServicesAuthPrompt(
+      const nsCString& aOnionName, const nsCString& aTopic);
+
   mozilla::ipc::IPCResult RecvSetSystemFont(const nsCString& aFontName);
   mozilla::ipc::IPCResult RecvGetSystemFont(nsCString* aFontName);
 
diff --git a/dom/ipc/PBrowser.ipdl b/dom/ipc/PBrowser.ipdl
index ed31b32a4eb2..f36e2c6db353 100644
--- a/dom/ipc/PBrowser.ipdl
+++ b/dom/ipc/PBrowser.ipdl
@@ -592,6 +592,15 @@ parent:
                              bool aNeedCollectSHistory, uint32_t aFlushId,
                              bool aIsFinal, uint32_t aEpoch);
 
+    /**
+     * This function is used to notify the parent that it should display a
+     * onion services client authentication prompt.
+     *
+     * @param aOnionHost The hostname of the .onion that needs authentication.
+     * @param aTopic The reason for the prompt.
+     */
+    async ShowOnionServicesAuthPrompt(nsCString aOnionHost, nsCString aTopic);
+
 child:
     async NativeSynthesisResponse(uint64_t aObserverId, nsCString aResponse);
     async FlushTabState(uint32_t aFlushId, bool aIsFinal);
diff --git a/js/xpconnect/src/xpc.msg b/js/xpconnect/src/xpc.msg
index d884c6a85999..31e5e75ba35c 100644
--- a/js/xpconnect/src/xpc.msg
+++ b/js/xpconnect/src/xpc.msg
@@ -253,5 +253,15 @@ XPC_MSG_DEF(NS_ERROR_FINGERPRINTING_URI               , "The URI is fingerprinti
 XPC_MSG_DEF(NS_ERROR_CRYPTOMINING_URI                 , "The URI is cryptomining")
 XPC_MSG_DEF(NS_ERROR_SOCIALTRACKING_URI               , "The URI is social tracking")
 
+/* Codes related to Tor */
+XPC_MSG_DEF(NS_ERROR_TOR_ONION_SVC_NOT_FOUND          , "Tor onion service descriptor cannot be found")
+XPC_MSG_DEF(NS_ERROR_TOR_ONION_SVC_IS_INVALID         , "Tor onion service descriptor is invalid")
+XPC_MSG_DEF(NS_ERROR_TOR_ONION_SVC_INTRO_FAILED       , "Tor onion service introduction failed")
+XPC_MSG_DEF(NS_ERROR_TOR_ONION_SVC_REND_FAILED        , "Tor onion service rendezvous failed")
+XPC_MSG_DEF(NS_ERROR_TOR_ONION_SVC_MISSING_CLIENT_AUTH, "Tor onion service missing client authorization")
+XPC_MSG_DEF(NS_ERROR_TOR_ONION_SVC_BAD_CLIENT_AUTH    , "Tor onion service wrong client authorization")
+XPC_MSG_DEF(NS_ERROR_TOR_ONION_SVC_BAD_ADDRESS        , "Tor onion service bad address")
+XPC_MSG_DEF(NS_ERROR_TOR_ONION_SVC_INTRO_TIMEDOUT     , "Tor onion service introduction timed out")
+
 /* Profile manager error codes */
 XPC_MSG_DEF(NS_ERROR_DATABASE_CHANGED                 , "Flushing the profiles to disk would have overwritten changes made elsewhere.")
diff --git a/netwerk/base/nsSocketTransport2.cpp b/netwerk/base/nsSocketTransport2.cpp
index 7256280697c8..ce739e1f0de8 100644
--- a/netwerk/base/nsSocketTransport2.cpp
+++ b/netwerk/base/nsSocketTransport2.cpp
@@ -215,6 +215,12 @@ nsresult ErrorAccordingToNSPR(PRErrorCode errorCode) {
     default:
       if (psm::IsNSSErrorCode(errorCode)) {
         rv = psm::GetXPCOMFromNSSError(errorCode);
+      } else {
+        // If we received a Tor extended error code via SOCKS, pass it through.
+        nsresult res = nsresult(errorCode);
+        if (NS_ERROR_GET_MODULE(res) == NS_ERROR_MODULE_TOR) {
+          rv = res;
+        }
       }
       break;
 
diff --git a/netwerk/socket/nsSOCKSIOLayer.cpp b/netwerk/socket/nsSOCKSIOLayer.cpp
index 6f2743ed5c71..57af13a7c026 100644
--- a/netwerk/socket/nsSOCKSIOLayer.cpp
+++ b/netwerk/socket/nsSOCKSIOLayer.cpp
@@ -1004,6 +1004,55 @@ PRStatus nsSOCKSSocketInfo::ReadV5ConnectResponseTop() {
              "08, Address type not supported."));
         c = PR_BAD_ADDRESS_ERROR;
         break;
+      case 0xF0:  // Tor SOCKS5_HS_NOT_FOUND
+        LOGERROR(
+            ("socks5: connect failed: F0,"
+             " Tor onion service descriptor can not be found."));
+        c = static_cast<uint32_t>(NS_ERROR_TOR_ONION_SVC_NOT_FOUND);
+        break;
+      case 0xF1:  // Tor SOCKS5_HS_IS_INVALID
+        LOGERROR(
+            ("socks5: connect failed: F1,"
+             " Tor onion service descriptor is invalid."));
+        c = static_cast<uint32_t>(NS_ERROR_TOR_ONION_SVC_IS_INVALID);
+        break;
+      case 0xF2:  // Tor SOCKS5_HS_INTRO_FAILED
+        LOGERROR(
+            ("socks5: connect failed: F2,"
+             " Tor onion service introduction failed."));
+        c = static_cast<uint32_t>(NS_ERROR_TOR_ONION_SVC_INTRO_FAILED);
+        break;
+      case 0xF3:  // Tor SOCKS5_HS_REND_FAILED
+        LOGERROR(
+            ("socks5: connect failed: F3,"
+             " Tor onion service rendezvous failed."));
+        c = static_cast<uint32_t>(NS_ERROR_TOR_ONION_SVC_REND_FAILED);
+        break;
+      case 0xF4:  // Tor SOCKS5_HS_MISSING_CLIENT_AUTH
+        LOGERROR(
+            ("socks5: connect failed: F4,"
+             " Tor onion service missing client authorization."));
+        c = static_cast<uint32_t>(NS_ERROR_TOR_ONION_SVC_MISSING_CLIENT_AUTH);
+        break;
+      case 0xF5:  // Tor SOCKS5_HS_BAD_CLIENT_AUTH
+        LOGERROR(
+            ("socks5: connect failed: F5,"
+             " Tor onion service wrong client authorization."));
+        c = static_cast<uint32_t>(NS_ERROR_TOR_ONION_SVC_BAD_CLIENT_AUTH);
+        break;
+      case 0xF6:  // Tor SOCKS5_HS_BAD_ADDRESS
+        LOGERROR(
+            ("socks5: connect failed: F6,"
+             " Tor onion service bad address."));
+        c = static_cast<uint32_t>(NS_ERROR_TOR_ONION_SVC_BAD_ADDRESS);
+        break;
+      case 0xF7:  // Tor SOCKS5_HS_INTRO_TIMEDOUT
+        LOGERROR(
+            ("socks5: connect failed: F7,"
+             " Tor onion service introduction timed out."));
+        c = static_cast<uint32_t>(NS_ERROR_TOR_ONION_SVC_INTRO_TIMEDOUT);
+        break;
+
       default:
         LOGERROR(("socks5: connect failed."));
         break;
diff --git a/toolkit/modules/PopupNotifications.jsm b/toolkit/modules/PopupNotifications.jsm
index d31f91ab00a5..6886c0b13c5d 100644
--- a/toolkit/modules/PopupNotifications.jsm
+++ b/toolkit/modules/PopupNotifications.jsm
@@ -406,6 +406,8 @@ PopupNotifications.prototype = {
    *            will be dismissed instead of removed after running the callback.
    *          - [optional] disabled (boolean): If this is true, the button
    *            will be disabled.
+  *           - [optional] leaveOpen (boolean): If this is true, the notification
+   *            will not be removed after running the callback.
    *          - [optional] disableHighlight (boolean): If this is true, the button
    *            will not apply the default highlight style.
    *        If null, the notification will have a default "OK" action button
@@ -1884,6 +1886,10 @@ PopupNotifications.prototype = {
         this._dismiss();
         return;
       }
+
+      if (action.leaveOpen) {
+        return;
+      }
     }
 
     this._remove(notification);
diff --git a/toolkit/modules/RemotePageAccessManager.jsm b/toolkit/modules/RemotePageAccessManager.jsm
index 9734324cbd5f..eceaa7c857de 100644
--- a/toolkit/modules/RemotePageAccessManager.jsm
+++ b/toolkit/modules/RemotePageAccessManager.jsm
@@ -95,6 +95,7 @@ let RemotePageAccessManager = {
       ],
       RPMPrefIsLocked: ["security.tls.version.min"],
       RPMAddToHistogram: ["*"],
+      RPMGetTorStrings: ["*"],
     },
     "about:newinstall": {
       RPMGetUpdateChannel: ["*"],
diff --git a/tools/lint/eslint/eslint-plugin-mozilla/lib/environments/frame-script.js b/tools/lint/eslint/eslint-plugin-mozilla/lib/environments/frame-script.js
index 28f5d864b0bd..cd0d6d4d3656 100644
--- a/tools/lint/eslint/eslint-plugin-mozilla/lib/environments/frame-script.js
+++ b/tools/lint/eslint/eslint-plugin-mozilla/lib/environments/frame-script.js
@@ -37,5 +37,6 @@ module.exports = {
     RPMRecordTelemetryEvent: false,
     RPMAddToHistogram: false,
     RPMRemoveMessageListener: false,
+    RPMGetTorStrings: false,
   },
 };
diff --git a/xpcom/base/ErrorList.py b/xpcom/base/ErrorList.py
index 6b1a05a91b31..5f35cf7771f9 100755
--- a/xpcom/base/ErrorList.py
+++ b/xpcom/base/ErrorList.py
@@ -85,6 +85,7 @@ modules["URL_CLASSIFIER"] = Mod(42)
 # ErrorResult gets its own module to reduce the chance of someone accidentally
 # defining an error code matching one of the ErrorResult ones.
 modules["ERRORRESULT"] = Mod(43)
+modules["TOR"] = Mod(44)
 
 # NS_ERROR_MODULE_GENERAL should be used by modules that do not
 # care if return code values overlap. Callers of methods that
@@ -1179,6 +1180,27 @@ with modules["ERRORRESULT"]:
     errors["NS_ERROR_INTERNAL_ERRORRESULT_RANGEERROR"] = FAILURE(5)
 
 
+# =======================================================================
+# 44: Tor-specific error codes.
+# =======================================================================
+with modules["TOR"]:
+    # Tor onion service descriptor can not be found.
+    errors["NS_ERROR_TOR_ONION_SVC_NOT_FOUND"] = FAILURE(1)
+    # Tor onion service descriptor is invalid.
+    errors["NS_ERROR_TOR_ONION_SVC_IS_INVALID"] = FAILURE(2)
+    # Tor onion service introduction failed.
+    errors["NS_ERROR_TOR_ONION_SVC_INTRO_FAILED"] = FAILURE(3)
+    # Tor onion service rendezvous failed.
+    errors["NS_ERROR_TOR_ONION_SVC_REND_FAILED"] = FAILURE(4)
+    # Tor onion service missing client authorization.
+    errors["NS_ERROR_TOR_ONION_SVC_MISSING_CLIENT_AUTH"] = FAILURE(5)
+    # Tor onion service wrong client authorization.
+    errors["NS_ERROR_TOR_ONION_SVC_BAD_CLIENT_AUTH"] = FAILURE(6)
+    # Tor onion service bad address.
+    errors["NS_ERROR_TOR_ONION_SVC_BAD_ADDRESS"] = FAILURE(7)
+    # Tor onion service introduction timed out.
+    errors["NS_ERROR_TOR_ONION_SVC_INTRO_TIMEDOUT"] = FAILURE(8)
+
 # =======================================================================
 # 51: NS_ERROR_MODULE_GENERAL
 # =======================================================================





More information about the tbb-commits mailing list