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