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 };