tor-browser

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

WebAuthnPromptHelper.sys.mjs (12538B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
      4 
      5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
      6 
      7 const lazy = {};
      8 
      9 XPCOMUtils.defineLazyServiceGetter(
     10  lazy,
     11  "webauthnService",
     12  "@mozilla.org/webauthn/service;1",
     13  Ci.nsIWebAuthnService
     14 );
     15 
     16 export let WebAuthnPromptHelper = {
     17  _icon: "webauthn-notification-icon",
     18  _topic: "webauthn-prompt",
     19 
     20  // The current notification, if any. The U2F manager is a singleton, we will
     21  // never allow more than one active request. And thus we'll never have more
     22  // than one notification either.
     23  _current: null,
     24 
     25  // The current transaction ID. Will be checked when we're notified of the
     26  // cancellation of an ongoing WebAuthhn request.
     27  _tid: 0,
     28 
     29  // Translation object
     30  _l10n: new Localization(
     31    ["branding/brand.ftl", "browser/webauthnDialog.ftl"],
     32    true
     33  ),
     34 
     35  observe(aSubject, aTopic, aData) {
     36    switch (aTopic) {
     37      case "fullscreen-nav-toolbox":
     38        // Prevent the navigation toolbox from being hidden while a WebAuthn
     39        // prompt is visible.
     40        if (aData == "hidden" && this._tid != 0) {
     41          aSubject.ownerGlobal.FullScreen.showNavToolbox();
     42        }
     43        return;
     44      case "fullscreen-painted":
     45        // Prevent DOM elements from going fullscreen while a WebAuthn
     46        // prompt is shown.
     47        if (this._tid != 0) {
     48          aSubject.FullScreen.exitDomFullScreen();
     49        }
     50        return;
     51      case this._topic:
     52        break;
     53      default:
     54        return;
     55    }
     56    // aTopic is equal to this._topic
     57 
     58    let data = JSON.parse(aData);
     59 
     60    // If we receive a cancel, it might be a WebAuthn prompt starting in another
     61    // window, and the other window's browsing context will send out the
     62    // cancellations, so any cancel action we get should prompt us to cancel.
     63    if (data.prompt.type == "cancel") {
     64      this.cancel(data);
     65      return;
     66    }
     67 
     68    let browsingContext = BrowsingContext.get(data.browsingContextId);
     69 
     70    if (data.prompt.type == "presence") {
     71      this.presence_required(browsingContext, data);
     72    } else if (data.prompt.type == "attestation-consent") {
     73      this.attestation_consent(browsingContext, data);
     74    } else if (data.prompt.type == "pin-required") {
     75      this.pin_required(browsingContext, false, data);
     76    } else if (data.prompt.type == "pin-invalid") {
     77      this.pin_required(browsingContext, true, data);
     78    } else if (data.prompt.type == "select-sign-result") {
     79      this.select_sign_result(browsingContext, data);
     80    } else if (data.prompt.type == "already-registered") {
     81      this.show_info(
     82        browsingContext,
     83        data.origin,
     84        data.tid,
     85        "alreadyRegistered",
     86        "webauthn-already-registered-prompt"
     87      );
     88    } else if (data.prompt.type == "select-device") {
     89      this.show_info(
     90        browsingContext,
     91        data.origin,
     92        data.tid,
     93        "selectDevice",
     94        "webauthn-select-device-prompt"
     95      );
     96    } else if (data.prompt.type == "pin-auth-blocked") {
     97      this.show_info(
     98        browsingContext,
     99        data.origin,
    100        data.tid,
    101        "pinAuthBlocked",
    102        "webauthn-pin-auth-blocked-prompt"
    103      );
    104    } else if (data.prompt.type == "uv-blocked") {
    105      this.show_info(
    106        browsingContext,
    107        data.origin,
    108        data.tid,
    109        "uvBlocked",
    110        "webauthn-uv-blocked-prompt"
    111      );
    112    } else if (data.prompt.type == "uv-invalid") {
    113      let retriesLeft = data.prompt.retries;
    114      let dialogText;
    115      if (retriesLeft === 0) {
    116        // We can skip that because it will either be replaced
    117        // by uv-blocked or by PIN-prompt
    118        return;
    119      } else if (retriesLeft == null || retriesLeft < 0) {
    120        dialogText = this._l10n.formatValueSync(
    121          "webauthn-uv-invalid-short-prompt"
    122        );
    123      } else {
    124        dialogText = this._l10n.formatValueSync(
    125          "webauthn-uv-invalid-long-prompt",
    126          { retriesLeft }
    127        );
    128      }
    129      let mainAction = this.buildCancelAction(data.tid);
    130      this.show_formatted_msg(
    131        browsingContext,
    132        data.tid,
    133        "uvInvalid",
    134        dialogText,
    135        mainAction
    136      );
    137    } else if (data.prompt.type == "device-blocked") {
    138      this.show_info(
    139        browsingContext,
    140        data.origin,
    141        data.tid,
    142        "deviceBlocked",
    143        "webauthn-device-blocked-prompt"
    144      );
    145    } else if (data.prompt.type == "pin-not-set") {
    146      this.show_info(
    147        browsingContext,
    148        data.origin,
    149        data.tid,
    150        "pinNotSet",
    151        "webauthn-pin-not-set-prompt"
    152      );
    153    }
    154  },
    155 
    156  prompt_for_password(
    157    browsingContext,
    158    origin,
    159    wasInvalid,
    160    retriesLeft,
    161    aPassword
    162  ) {
    163    this.reset();
    164    let dialogText;
    165    if (!wasInvalid) {
    166      dialogText = this._l10n.formatValueSync("webauthn-pin-required-prompt");
    167    } else if (retriesLeft == null || retriesLeft < 0 || retriesLeft > 3) {
    168      // The token will need to be power cycled after three incorrect attempts,
    169      // so we show a short error message that does not include retriesLeft. It
    170      // would be confusing to display retriesLeft at this point, as the user
    171      // will feel that they only get three attempts.
    172      // We also only show the short prompt in the case the token doesn't
    173      // support/send a retries-counter. Then we simply don't know how many are left.
    174      dialogText = this._l10n.formatValueSync(
    175        "webauthn-pin-invalid-short-prompt"
    176      );
    177    } else {
    178      // The user is close to having their PIN permanently blocked. Show a more
    179      // severe warning that includes the retriesLeft counter.
    180      dialogText = this._l10n.formatValueSync(
    181        "webauthn-pin-invalid-long-prompt",
    182        { retriesLeft }
    183      );
    184    }
    185 
    186    let res = Services.prompt.promptPasswordBC(
    187      browsingContext,
    188      Services.prompt.MODAL_TYPE_TAB,
    189      origin,
    190      dialogText,
    191      aPassword
    192    );
    193    return res;
    194  },
    195 
    196  select_sign_result(browsingContext, { origin, tid, prompt: { entities } }) {
    197    let unknownAccount = this._l10n.formatValueSync(
    198      "webauthn-select-sign-result-unknown-account"
    199    );
    200    let secondaryActions = [];
    201    for (let i = 0; i < entities.length; i++) {
    202      let label = entities[i].name ?? unknownAccount;
    203      secondaryActions.push({
    204        label,
    205        accessKey: i.toString(),
    206        callback() {
    207          lazy.webauthnService.selectionCallback(tid, i);
    208        },
    209      });
    210    }
    211    let mainAction = this.buildCancelAction(tid);
    212    let options = { escAction: "buttoncommand" };
    213    this.show(
    214      browsingContext,
    215      tid,
    216      "select-sign-result",
    217      "webauthn-select-sign-result-prompt",
    218      origin,
    219      mainAction,
    220      secondaryActions,
    221      options
    222    );
    223  },
    224 
    225  pin_required(
    226    browsingContext,
    227    wasInvalid,
    228    { origin, tid, prompt: { retries } }
    229  ) {
    230    let aPassword = Object.create(null); // create a "null" object
    231    let res = this.prompt_for_password(
    232      browsingContext,
    233      origin,
    234      wasInvalid,
    235      retries,
    236      aPassword
    237    );
    238    if (res) {
    239      lazy.webauthnService.pinCallback(tid, aPassword.value);
    240    } else {
    241      lazy.webauthnService.cancel(tid);
    242    }
    243  },
    244 
    245  presence_required(browsingContext, { origin, tid }) {
    246    let mainAction = this.buildCancelAction(tid);
    247    let options = { escAction: "buttoncommand" };
    248    let secondaryActions = [];
    249    let message = "webauthn-user-presence-prompt";
    250    this.show(
    251      browsingContext,
    252      tid,
    253      "presence",
    254      message,
    255      origin,
    256      mainAction,
    257      secondaryActions,
    258      options
    259    );
    260  },
    261 
    262  attestation_consent(browsingContext, { origin, tid }) {
    263    let [allowMsg, blockMsg] = this._l10n.formatMessagesSync([
    264      { id: "webauthn-allow" },
    265      { id: "webauthn-block" },
    266    ]);
    267    let mainAction = {
    268      label: allowMsg.value,
    269      accessKey: allowMsg.attributes.find(a => a.name == "accesskey").value,
    270      callback(_state) {
    271        lazy.webauthnService.setHasAttestationConsent(tid, true);
    272      },
    273    };
    274    let secondaryActions = [
    275      {
    276        label: blockMsg.value,
    277        accessKey: blockMsg.attributes.find(a => a.name == "accesskey").value,
    278        callback(_state) {
    279          lazy.webauthnService.setHasAttestationConsent(tid, false);
    280        },
    281      },
    282    ];
    283 
    284    let learnMoreURL =
    285      Services.urlFormatter.formatURLPref("app.support.baseURL") +
    286      "webauthn-direct-attestation";
    287 
    288    let options = {
    289      learnMoreURL,
    290      hintText: this._l10n.formatValueSync(
    291        "webauthn-register-direct-prompt-hint"
    292      ),
    293    };
    294    this.show(
    295      browsingContext,
    296      tid,
    297      "register-direct",
    298      "webauthn-register-direct-prompt",
    299      origin,
    300      mainAction,
    301      secondaryActions,
    302      options
    303    );
    304  },
    305 
    306  /**
    307   * Show a message with cancel as the default action.
    308   *
    309   * @param {BrowsingContext} browsingContext
    310   * @param {string} origin
    311   * @param {number} tid
    312   * @param {string} id
    313   * @param {string} stringId
    314   */
    315  show_info(browsingContext, origin, tid, id, stringId) {
    316    let mainAction = this.buildCancelAction(tid);
    317    this.show(browsingContext, tid, id, stringId, origin, mainAction);
    318  },
    319 
    320  show(
    321    browsingContext,
    322    tid,
    323    id,
    324    stringId,
    325    origin,
    326    mainAction,
    327    secondaryActions = [],
    328    options = {}
    329  ) {
    330    let message = this._l10n.formatValueSync(stringId, { hostname: "<>" });
    331 
    332    try {
    333      origin = Services.io.newURI(origin).asciiHost;
    334    } catch (e) {
    335      /* Might fail for arbitrary U2F RP IDs. */
    336    }
    337    options.name = origin;
    338    this.show_formatted_msg(
    339      browsingContext,
    340      tid,
    341      id,
    342      message,
    343      mainAction,
    344      secondaryActions,
    345      options
    346    );
    347  },
    348 
    349  /**
    350   * Show a PopupNotification instance.
    351   *
    352   * @param {CanonicalBrowsingContext} browsingContext
    353   * @param {number} tid
    354   *                 The identifier used by the WebAuthn service.
    355   * @param {string} id
    356   *                 The id to use for the notification.
    357   * @param {string} message
    358   *                 The message to display in the notification.
    359   * @param {object} mainAction
    360   *                 The main button for the notification.
    361   * @param {Array<object>?} secondaryActions
    362   *                 The secondary buttons for the notification.
    363   * @param {object?} options
    364   *                 Additional options for the notification.
    365   *                 See PopupNotifications.sys.mjs for more details.
    366   */
    367  show_formatted_msg(
    368    browsingContext,
    369    tid,
    370    id,
    371    message,
    372    mainAction,
    373    secondaryActions = [],
    374    options = {}
    375  ) {
    376    this.reset();
    377    this._tid = tid;
    378 
    379    // We need to prevent some fullscreen transitions while WebAuthn prompts
    380    // are shown. The `fullscreen-painted` topic is notified when DOM elements
    381    // go fullscreen.
    382    Services.obs.addObserver(this, "fullscreen-painted");
    383 
    384    // The `fullscreen-nav-toolbox` topic is notified when the nav toolbox is
    385    // hidden.
    386    Services.obs.addObserver(this, "fullscreen-nav-toolbox");
    387 
    388    let chromeWin = browsingContext.topChromeWindow;
    389 
    390    // Ensure that no DOM elements are already fullscreen.
    391    chromeWin.FullScreen.exitDomFullScreen();
    392 
    393    // Ensure that the nav toolbox is being shown.
    394    if (chromeWin.fullScreen) {
    395      chromeWin.FullScreen.showNavToolbox();
    396    }
    397 
    398    options.hideClose = true;
    399    options.persistent = true;
    400    options.eventCallback = event => {
    401      if (event == "removed") {
    402        Services.obs.removeObserver(this, "fullscreen-painted");
    403        Services.obs.removeObserver(this, "fullscreen-nav-toolbox");
    404        this._current = null;
    405        this._tid = 0;
    406      }
    407    };
    408 
    409    this._current = chromeWin.PopupNotifications.show(
    410      browsingContext.top.embedderElement,
    411      `webauthn-prompt-${id}`,
    412      message,
    413      this._icon,
    414      mainAction,
    415      secondaryActions,
    416      options
    417    );
    418  },
    419 
    420  cancel({ tid }) {
    421    if (this._tid == tid) {
    422      this.reset();
    423    }
    424  },
    425 
    426  reset() {
    427    if (this._current) {
    428      this._current.remove();
    429    }
    430  },
    431 
    432  buildCancelAction(tid) {
    433    let [cancelMsg] = this._l10n.formatMessagesSync([
    434      { id: "webauthn-cancel" },
    435    ]);
    436    return {
    437      label: cancelMsg.value,
    438      accessKey: cancelMsg.attributes.find(a => a.name == "accesskey").value,
    439      callback() {
    440        lazy.webauthnService.cancel(tid);
    441      },
    442    };
    443  },
    444 };