tor-browser

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

authPrompt.js (13948B)


      1 "use strict";
      2 
      3 var OnionAuthPrompt = {
      4  // Only import to our internal scope, rather than the global scope of
      5  // browser.xhtml.
      6  _lazy: {},
      7 
      8  /**
      9   * The topics to listen to.
     10   *
     11   * @type {{[key: string]: string}}
     12   */
     13  _topics: {
     14    clientAuthMissing: "tor-onion-services-clientauth-missing",
     15    clientAuthIncorrect: "tor-onion-services-clientauth-incorrect",
     16  },
     17 
     18  /**
     19   * @typedef {object} PromptDetails
     20   *
     21   * @property {Browser} browser - The browser this prompt is for.
     22   * @property {string} cause - The notification that cause this prompt.
     23   * @property {string} onionHost - The onion host name.
     24   * @property {nsIURI} uri - The browser URI when the notification was
     25   *   triggered.
     26   * @property {string} onionServiceId - The onion service ID for this host.
     27   * @property {Notification} [notification] - The notification instance for
     28   *   this prompt.
     29   */
     30 
     31  /**
     32   * The currently shown details in the prompt.
     33   *
     34   * @type {?PromptDetails}
     35   */
     36  _shownDetails: null,
     37 
     38  /**
     39   * Used for logging to represent PromptDetails.
     40   *
     41   * @param {PromptDetails} details - The details to represent.
     42   * @returns {string} - The representation of these details.
     43   */
     44  _detailsRepr(details) {
     45    if (!details) {
     46      return "none";
     47    }
     48    return `${details.browser.browserId}:${details.onionHost}`;
     49  },
     50 
     51  /**
     52   * Show a new prompt, using the given details.
     53   *
     54   * @param {PromptDetails} details - The details to show.
     55   */
     56  show(details) {
     57    this._logger.debug(`New Notification: ${this._detailsRepr(details)}`);
     58 
     59    // NOTE: PopupNotifications currently requires the accesskey and label to be
     60    // set for all actions, and does not accept fluent IDs in their place.
     61    // Moreover, there doesn't appear to be a simple way to work around this, so
     62    // we have to fetch the strings here before calling the show() method.
     63    // NOTE: We avoid using the async formatMessages because we don't want to
     64    // race against the browser's location changing.
     65    // In principle, we could check that the details.browser.currentURI still
     66    // matches details.uri or use a LocationChange listener. However, we expect
     67    // that PopupNotifications will eventually change to accept fluent IDs, so
     68    // we won't have to use formatMessages here at all.
     69    // Moreover, we do not expect this notification to be common, so this
     70    // shouldn't be too expensive.
     71    // NOTE: Once we call PopupNotifications.show, PopupNotifications should
     72    // take care of listening for changes in locations for us and remove the
     73    // notification.
     74    let [okButtonMsg, cancelButtonMsg] = this._lazy.SyncL10n.formatMessagesSync(
     75      [
     76        "onion-site-authentication-prompt-ok-button",
     77        "onion-site-authentication-prompt-cancel-button",
     78      ]
     79    );
     80 
     81    // Get an attribute string from a L10nMessage.
     82    // We wrap the return value as a String to prevent the notification from
     83    // throwing (and not showing) if a locale is unexpectedly missing a value.
     84    const msgAttribute = (msg, name) =>
     85      String((msg.attributes ?? []).find(attr => attr.name === name)?.value);
     86 
     87    let mainAction = {
     88      label: msgAttribute(okButtonMsg, "label"),
     89      accessKey: msgAttribute(okButtonMsg, "accesskey"),
     90      leaveOpen: true, // Callback is responsible for closing the notification.
     91      callback: () => this._onDone(),
     92    };
     93 
     94    // The first secondarybuttoncommand (cancelAction) should be triggered when
     95    // the user presses "Escape".
     96    let cancelAction = {
     97      label: msgAttribute(cancelButtonMsg, "label"),
     98      accessKey: msgAttribute(cancelButtonMsg, "accesskey"),
     99      callback: () => this._onCancel(),
    100    };
    101 
    102    let options = {
    103      autofocus: true,
    104      hideClose: true,
    105      persistent: true,
    106      removeOnDismissal: false,
    107      eventCallback: topic => {
    108        if (topic === "showing") {
    109          this._onPromptShowing(details);
    110        } else if (topic === "shown") {
    111          this._onPromptShown();
    112        } else if (topic === "removed") {
    113          this._onPromptRemoved(details);
    114        }
    115      },
    116    };
    117 
    118    details.notification = PopupNotifications.show(
    119      details.browser,
    120      "tor-clientauth",
    121      "",
    122      "tor-clientauth-notification-icon",
    123      mainAction,
    124      [cancelAction],
    125      options
    126    );
    127  },
    128 
    129  /**
    130   * Callback when the prompt is about to be shown.
    131   *
    132   * @param {PromptDetails?} details - The details to show, or null to shown
    133   *   none.
    134   */
    135  _onPromptShowing(details) {
    136    if (details === this._shownDetails) {
    137      // The last shown details match this one exactly.
    138      // This happens when we switch tabs to a page that has no prompt and then
    139      // switch back.
    140      // We don't want to reset the current state in this case.
    141      // In particular, we keep the current _keyInput value and _persistCheckbox
    142      // the same.
    143      this._logger.debug(`Already showing: ${this._detailsRepr(details)}`);
    144      return;
    145    }
    146 
    147    this._logger.debug(`Now showing: ${this._detailsRepr(details)}`);
    148 
    149    this._shownDetails = details;
    150 
    151    // Clear the key input.
    152    // In particular, clear the input when switching tabs.
    153    this._keyInput.value = "";
    154    this._persistCheckbox.checked = false;
    155 
    156    document.l10n.setAttributes(
    157      this._descriptionEl,
    158      "onion-site-authentication-prompt-description",
    159      {
    160        onionsite: TorUIUtils.shortenOnionAddress(
    161          this._shownDetails?.onionHost ?? ""
    162        ),
    163      }
    164    );
    165 
    166    this._showWarning(null);
    167  },
    168 
    169  /**
    170   * Callback after the prompt is shown.
    171   */
    172  _onPromptShown() {
    173    this._keyInput.focus();
    174  },
    175 
    176  /**
    177   * Callback when a Notification is removed.
    178   *
    179   * @param {PromptDetails} details - The details for the removed notification.
    180   */
    181  _onPromptRemoved(details) {
    182    if (details !== this._shownDetails) {
    183      // Removing the notification for some other page.
    184      // For example, closing another tab that also requires authentication.
    185      this._logger.debug(`Removed not shown: ${this._detailsRepr(details)}`);
    186      return;
    187    }
    188    this._logger.debug(`Removed shown: ${this._detailsRepr(details)}`);
    189    // Reset the prompt as a precaution.
    190    // In particular, we want to clear the input so that the entered key does
    191    // not persist.
    192    this._onPromptShowing(null);
    193  },
    194 
    195  /**
    196   * Callback when the user submits the key.
    197   */
    198  async _onDone() {
    199    this._logger.debug(
    200      `Sumbitting key: ${this._detailsRepr(this._shownDetails)}`
    201    );
    202 
    203    // Grab the details before they might change as we await.
    204    const details = this._shownDetails;
    205    const { browser, onionServiceId, notification } = details;
    206    const isPermanent = this._persistCheckbox.checked;
    207 
    208    const base64key = this._keyToBase64(this._keyInput.value);
    209    if (!base64key) {
    210      this._showWarning("onion-site-authentication-prompt-invalid-key");
    211      return;
    212    }
    213 
    214    try {
    215      const provider = await this._lazy.TorProviderBuilder.build();
    216      await provider.onionAuthAdd(onionServiceId, base64key, isPermanent);
    217    } catch (e) {
    218      this._logger.error(`Failed to set key for ${onionServiceId}`, e);
    219      if (details === this._shownDetails) {
    220        // Notification has not been replaced.
    221        this._showWarning(
    222          "onion-site-authentication-prompt-setting-key-failed"
    223        );
    224      }
    225      return;
    226    }
    227 
    228    notification.remove();
    229    // Success! Reload the page.
    230    browser.reload();
    231  },
    232 
    233  /**
    234   * Callback when the user dismisses the prompt.
    235   */
    236  _onCancel() {
    237    // Arrange for an error page to be displayed:
    238    // we build a short script calling docShell.displayError()
    239    // and we pass it as a data: URI to loadFrameScript(),
    240    // which runs it in the content frame which triggered
    241    // this authentication prompt.
    242    this._logger.debug(`Cancelling: ${this._detailsRepr(this._shownDetails)}`);
    243 
    244    const { browser, cause, uri } = this._shownDetails;
    245    const errorCode =
    246      cause === this._topics.clientAuthMissing
    247        ? Cr.NS_ERROR_TOR_ONION_SVC_MISSING_CLIENT_AUTH
    248        : Cr.NS_ERROR_TOR_ONION_SVC_BAD_CLIENT_AUTH;
    249    browser.messageManager.loadFrameScript(
    250      `data:application/javascript,${encodeURIComponent(
    251        `docShell.displayLoadError(${errorCode}, Services.io.newURI(${JSON.stringify(
    252          uri.spec
    253        )}), undefined, undefined);`
    254      )}`,
    255      false
    256    );
    257  },
    258 
    259  /**
    260   * Show a warning message to the user or clear the warning.
    261   *
    262   * @param {?string} warningMessageId - The l10n ID for the message to show, or
    263   *   null to clear the current message.
    264   */
    265  _showWarning(warningMessageId) {
    266    this._logger.debug(`Showing warning: ${warningMessageId}`);
    267 
    268    if (warningMessageId) {
    269      document.l10n.setAttributes(this._warningTextEl, warningMessageId);
    270      this._warningEl.removeAttribute("hidden");
    271      this._keyInput.classList.add("invalid");
    272      this._keyInput.setAttribute("aria-invalid", "true");
    273    } else {
    274      this._warningTextEl.removeAttribute("data-l10n-id");
    275      this._warningTextEl.textContent = "";
    276      this._warningEl.setAttribute("hidden", "true");
    277      this._keyInput.classList.remove("invalid");
    278      this._keyInput.removeAttribute("aria-invalid");
    279    }
    280  },
    281 
    282  /**
    283   * Convert the user-entered key into base64.
    284   *
    285   * @param {string} keyString - The key to convert.
    286   * @returns {?string} - The base64 representation, or undefined if the given
    287   *   key was not the correct format.
    288   */
    289  _keyToBase64(keyString) {
    290    if (!keyString) {
    291      return undefined;
    292    }
    293 
    294    let base64key;
    295    if (keyString.length === 52) {
    296      // The key is probably base32-encoded. Attempt to decode.
    297      // Although base32 specifies uppercase letters, we accept lowercase
    298      // as well because users may type in lowercase or copy a key out of
    299      // a tor onion-auth file (which uses lowercase).
    300      let rawKey;
    301      try {
    302        rawKey = this._lazy.CommonUtils.decodeBase32(keyString.toUpperCase());
    303      } catch (e) {}
    304 
    305      if (rawKey) {
    306        try {
    307          base64key = btoa(rawKey);
    308        } catch (e) {}
    309      }
    310    } else if (
    311      keyString.length === 44 &&
    312      /^[a-zA-Z0-9+/]*=*$/.test(keyString)
    313    ) {
    314      // The key appears to be a correctly formatted base64 value. If not,
    315      // tor will return an error when we try to add the key via the
    316      // control port.
    317      base64key = keyString;
    318    }
    319 
    320    return base64key;
    321  },
    322 
    323  /**
    324   * Initialize the authentication prompt.
    325   */
    326  init() {
    327    this._logger = console.createInstance({
    328      prefix: "OnionAuthPrompt",
    329      maxLogLevelPref: "browser.onionAuthPrompt.loglevel",
    330    });
    331 
    332    ChromeUtils.defineESModuleGetters(this._lazy, {
    333      TorProviderBuilder: "resource://gre/modules/TorProviderBuilder.sys.mjs",
    334      CommonUtils: "resource://services-common/utils.sys.mjs",
    335    });
    336    // Allow synchornous access to the localized strings. Used only for the
    337    // button actions, which is currently a hard requirement for
    338    // PopupNotifications.show. Hopefully, PopupNotifications will accept fluent
    339    // ids in their place, or get replaced with something else that does.
    340    ChromeUtils.defineLazyGetter(this._lazy, "SyncL10n", () => {
    341      return new Localization(["toolkit/global/tor-browser.ftl"], true);
    342    });
    343 
    344    this._keyInput = document.getElementById("tor-clientauth-notification-key");
    345    this._persistCheckbox = document.getElementById(
    346      "tor-clientauth-persistkey-checkbox"
    347    );
    348    this._warningEl = document.getElementById("tor-clientauth-warning");
    349    this._warningTextEl = document.getElementById(
    350      "tor-clientauth-warning-text"
    351    );
    352    this._descriptionEl = document.getElementById(
    353      "tor-clientauth-notification-desc"
    354    );
    355 
    356    this._keyInput.addEventListener("keydown", event => {
    357      if (event.key === "Enter") {
    358        event.preventDefault();
    359        this._onDone();
    360      }
    361    });
    362    this._keyInput.addEventListener("input", () => {
    363      // Remove the warning.
    364      this._showWarning(null);
    365    });
    366 
    367    // Force back focus on click: tor-browser#41856
    368    document
    369      .getElementById("tor-clientauth-notification")
    370      .addEventListener("click", () => {
    371        window.focus();
    372      });
    373 
    374    Services.obs.addObserver(this, this._topics.clientAuthMissing);
    375    Services.obs.addObserver(this, this._topics.clientAuthIncorrect);
    376  },
    377 
    378  /**
    379   * Un-initialize the authentication prompt.
    380   */
    381  uninit() {
    382    Services.obs.removeObserver(this, this._topics.clientAuthMissing);
    383    Services.obs.removeObserver(this, this._topics.clientAuthIncorrect);
    384  },
    385 
    386  observe(subject, topic, data) {
    387    if (
    388      topic !== this._topics.clientAuthMissing &&
    389      topic !== this._topics.clientAuthIncorrect
    390    ) {
    391      return;
    392    }
    393 
    394    // "subject" is the DOM window or browser where the prompt should be shown.
    395    let browser;
    396    if (subject instanceof Ci.nsIDOMWindow) {
    397      let contentWindow = subject.QueryInterface(Ci.nsIDOMWindow);
    398      browser = contentWindow.docShell.chromeEventHandler;
    399    } else {
    400      browser = subject.QueryInterface(Ci.nsIBrowser);
    401    }
    402 
    403    if (!gBrowser.browsers.includes(browser)) {
    404      // This window does not contain the subject browser.
    405      this._logger.debug(
    406        `Window ${window.docShell.outerWindowID}: Ignoring ${topic}`
    407      );
    408      return;
    409    }
    410    this._logger.debug(
    411      `Window ${window.docShell.outerWindowID}: Handling ${topic}`
    412    );
    413 
    414    const onionHost = data;
    415    // ^(subdomain.)*onionserviceid.onion$ (case-insensitive)
    416    const onionServiceId = onionHost
    417      .match(/^(.*\.)?(?<onionServiceId>[a-z2-7]{56})\.onion$/i)
    418      ?.groups.onionServiceId.toLowerCase();
    419    if (!onionServiceId) {
    420      this._logger.error(`Malformed onion address: ${onionHost}`);
    421      return;
    422    }
    423 
    424    const details = {
    425      browser,
    426      cause: topic,
    427      onionHost,
    428      uri: browser.currentURI,
    429      onionServiceId,
    430    };
    431    this.show(details);
    432  },
    433 };