445 lines
12 KiB
JavaScript
445 lines
12 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
|
|
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
|
|
|
|
const lazy = {};
|
|
|
|
XPCOMUtils.defineLazyServiceGetter(
|
|
lazy,
|
|
"webauthnService",
|
|
"@mozilla.org/webauthn/service;1",
|
|
Ci.nsIWebAuthnService
|
|
);
|
|
|
|
export let WebAuthnPromptHelper = {
|
|
_icon: "webauthn-notification-icon",
|
|
_topic: "webauthn-prompt",
|
|
|
|
// The current notification, if any. The U2F manager is a singleton, we will
|
|
// never allow more than one active request. And thus we'll never have more
|
|
// than one notification either.
|
|
_current: null,
|
|
|
|
// The current transaction ID. Will be checked when we're notified of the
|
|
// cancellation of an ongoing WebAuthhn request.
|
|
_tid: 0,
|
|
|
|
// Translation object
|
|
_l10n: new Localization(
|
|
["branding/brand.ftl", "toolkit/webauthnDialog.ftl"],
|
|
true
|
|
),
|
|
|
|
observe(aSubject, aTopic, aData) {
|
|
switch (aTopic) {
|
|
case "fullscreen-nav-toolbox":
|
|
// Prevent the navigation toolbox from being hidden while a WebAuthn
|
|
// prompt is visible.
|
|
if (aData == "hidden" && this._tid != 0) {
|
|
aSubject.documentGlobal.FullScreen.showNavToolbox();
|
|
}
|
|
return;
|
|
case "fullscreen-painted":
|
|
// Prevent DOM elements from going fullscreen while a WebAuthn
|
|
// prompt is shown.
|
|
if (this._tid != 0) {
|
|
aSubject.FullScreen.exitDomFullScreen();
|
|
}
|
|
return;
|
|
case this._topic:
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
// aTopic is equal to this._topic
|
|
|
|
let data = JSON.parse(aData);
|
|
|
|
// If we receive a cancel, it might be a WebAuthn prompt starting in another
|
|
// window, and the other window's browsing context will send out the
|
|
// cancellations, so any cancel action we get should prompt us to cancel.
|
|
if (data.prompt.type == "cancel") {
|
|
this.cancel(data);
|
|
return;
|
|
}
|
|
|
|
let browsingContext = BrowsingContext.get(data.browsingContextId);
|
|
|
|
if (data.prompt.type == "presence") {
|
|
this.presence_required(browsingContext, data);
|
|
} else if (data.prompt.type == "attestation-consent") {
|
|
this.attestation_consent(browsingContext, data);
|
|
} else if (data.prompt.type == "pin-required") {
|
|
this.pin_required(browsingContext, false, data);
|
|
} else if (data.prompt.type == "pin-invalid") {
|
|
this.pin_required(browsingContext, true, data);
|
|
} else if (data.prompt.type == "select-sign-result") {
|
|
this.select_sign_result(browsingContext, data);
|
|
} else if (data.prompt.type == "already-registered") {
|
|
this.show_info(
|
|
browsingContext,
|
|
data.origin,
|
|
data.tid,
|
|
"alreadyRegistered",
|
|
"webauthn-already-registered-prompt"
|
|
);
|
|
} else if (data.prompt.type == "select-device") {
|
|
this.show_info(
|
|
browsingContext,
|
|
data.origin,
|
|
data.tid,
|
|
"selectDevice",
|
|
"webauthn-select-device-prompt"
|
|
);
|
|
} else if (data.prompt.type == "pin-auth-blocked") {
|
|
this.show_info(
|
|
browsingContext,
|
|
data.origin,
|
|
data.tid,
|
|
"pinAuthBlocked",
|
|
"webauthn-pin-auth-blocked-prompt"
|
|
);
|
|
} else if (data.prompt.type == "uv-blocked") {
|
|
this.show_info(
|
|
browsingContext,
|
|
data.origin,
|
|
data.tid,
|
|
"uvBlocked",
|
|
"webauthn-uv-blocked-prompt"
|
|
);
|
|
} else if (data.prompt.type == "uv-invalid") {
|
|
let retriesLeft = data.prompt.retries;
|
|
let dialogText;
|
|
if (retriesLeft === 0) {
|
|
// We can skip that because it will either be replaced
|
|
// by uv-blocked or by PIN-prompt
|
|
return;
|
|
} else if (retriesLeft == null || retriesLeft < 0) {
|
|
dialogText = this._l10n.formatValueSync(
|
|
"webauthn-uv-invalid-short-prompt"
|
|
);
|
|
} else {
|
|
dialogText = this._l10n.formatValueSync(
|
|
"webauthn-uv-invalid-long-prompt",
|
|
{ retriesLeft }
|
|
);
|
|
}
|
|
let mainAction = this.buildCancelAction(data.tid);
|
|
this.show_formatted_msg(
|
|
browsingContext,
|
|
data.tid,
|
|
"uvInvalid",
|
|
dialogText,
|
|
mainAction
|
|
);
|
|
} else if (data.prompt.type == "device-blocked") {
|
|
this.show_info(
|
|
browsingContext,
|
|
data.origin,
|
|
data.tid,
|
|
"deviceBlocked",
|
|
"webauthn-device-blocked-prompt"
|
|
);
|
|
} else if (data.prompt.type == "pin-not-set") {
|
|
this.show_info(
|
|
browsingContext,
|
|
data.origin,
|
|
data.tid,
|
|
"pinNotSet",
|
|
"webauthn-pin-not-set-prompt"
|
|
);
|
|
}
|
|
},
|
|
|
|
prompt_for_password(
|
|
browsingContext,
|
|
origin,
|
|
wasInvalid,
|
|
retriesLeft,
|
|
aPassword
|
|
) {
|
|
this.reset();
|
|
let dialogText;
|
|
if (!wasInvalid) {
|
|
dialogText = this._l10n.formatValueSync("webauthn-pin-required-prompt");
|
|
} else if (retriesLeft == null || retriesLeft < 0 || retriesLeft > 3) {
|
|
// The token will need to be power cycled after three incorrect attempts,
|
|
// so we show a short error message that does not include retriesLeft. It
|
|
// would be confusing to display retriesLeft at this point, as the user
|
|
// will feel that they only get three attempts.
|
|
// We also only show the short prompt in the case the token doesn't
|
|
// support/send a retries-counter. Then we simply don't know how many are left.
|
|
dialogText = this._l10n.formatValueSync(
|
|
"webauthn-pin-invalid-short-prompt"
|
|
);
|
|
} else {
|
|
// The user is close to having their PIN permanently blocked. Show a more
|
|
// severe warning that includes the retriesLeft counter.
|
|
dialogText = this._l10n.formatValueSync(
|
|
"webauthn-pin-invalid-long-prompt",
|
|
{ retriesLeft }
|
|
);
|
|
}
|
|
|
|
let res = Services.prompt.promptPasswordBC(
|
|
browsingContext,
|
|
Services.prompt.MODAL_TYPE_TAB,
|
|
origin,
|
|
dialogText,
|
|
aPassword
|
|
);
|
|
return res;
|
|
},
|
|
|
|
select_sign_result(browsingContext, { origin, tid, prompt: { entities } }) {
|
|
let unknownAccount = this._l10n.formatValueSync(
|
|
"webauthn-select-sign-result-unknown-account"
|
|
);
|
|
let secondaryActions = [];
|
|
for (let i = 0; i < entities.length; i++) {
|
|
let label = entities[i].name ?? unknownAccount;
|
|
secondaryActions.push({
|
|
label,
|
|
accessKey: i.toString(),
|
|
callback() {
|
|
lazy.webauthnService.selectionCallback(tid, i);
|
|
},
|
|
});
|
|
}
|
|
let mainAction = this.buildCancelAction(tid);
|
|
let options = { escAction: "buttoncommand" };
|
|
this.show(
|
|
browsingContext,
|
|
tid,
|
|
"select-sign-result",
|
|
"webauthn-select-sign-result-prompt",
|
|
origin,
|
|
mainAction,
|
|
secondaryActions,
|
|
options
|
|
);
|
|
},
|
|
|
|
pin_required(
|
|
browsingContext,
|
|
wasInvalid,
|
|
{ origin, tid, prompt: { retries } }
|
|
) {
|
|
let aPassword = Object.create(null); // create a "null" object
|
|
let res = this.prompt_for_password(
|
|
browsingContext,
|
|
origin,
|
|
wasInvalid,
|
|
retries,
|
|
aPassword
|
|
);
|
|
if (res) {
|
|
lazy.webauthnService.pinCallback(tid, aPassword.value);
|
|
} else {
|
|
lazy.webauthnService.cancel(tid);
|
|
}
|
|
},
|
|
|
|
presence_required(browsingContext, { origin, tid }) {
|
|
let mainAction = this.buildCancelAction(tid);
|
|
let options = { escAction: "buttoncommand" };
|
|
let secondaryActions = [];
|
|
let message = "webauthn-user-presence-prompt";
|
|
this.show(
|
|
browsingContext,
|
|
tid,
|
|
"presence",
|
|
message,
|
|
origin,
|
|
mainAction,
|
|
secondaryActions,
|
|
options
|
|
);
|
|
},
|
|
|
|
attestation_consent(browsingContext, { origin, tid }) {
|
|
let [allowMsg, blockMsg] = this._l10n.formatMessagesSync([
|
|
{ id: "webauthn-allow" },
|
|
{ id: "webauthn-block" },
|
|
]);
|
|
let mainAction = {
|
|
label: allowMsg.value,
|
|
accessKey: allowMsg.attributes.find(a => a.name == "accesskey").value,
|
|
callback(_state) {
|
|
lazy.webauthnService.setHasAttestationConsent(tid, true);
|
|
},
|
|
};
|
|
let secondaryActions = [
|
|
{
|
|
label: blockMsg.value,
|
|
accessKey: blockMsg.attributes.find(a => a.name == "accesskey").value,
|
|
callback(_state) {
|
|
lazy.webauthnService.setHasAttestationConsent(tid, false);
|
|
},
|
|
},
|
|
];
|
|
|
|
let learnMoreURL =
|
|
Services.urlFormatter.formatURLPref("app.support.baseURL") +
|
|
"webauthn-direct-attestation";
|
|
|
|
let options = {
|
|
learnMoreURL,
|
|
hintText: this._l10n.formatValueSync(
|
|
"webauthn-register-direct-prompt-hint"
|
|
),
|
|
};
|
|
this.show(
|
|
browsingContext,
|
|
tid,
|
|
"register-direct",
|
|
"webauthn-register-direct-prompt",
|
|
origin,
|
|
mainAction,
|
|
secondaryActions,
|
|
options
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Show a message with cancel as the default action.
|
|
*
|
|
* @param {BrowsingContext} browsingContext
|
|
* @param {string} origin
|
|
* @param {number} tid
|
|
* @param {string} id
|
|
* @param {string} stringId
|
|
*/
|
|
show_info(browsingContext, origin, tid, id, stringId) {
|
|
let mainAction = this.buildCancelAction(tid);
|
|
this.show(browsingContext, tid, id, stringId, origin, mainAction);
|
|
},
|
|
|
|
show(
|
|
browsingContext,
|
|
tid,
|
|
id,
|
|
stringId,
|
|
origin,
|
|
mainAction,
|
|
secondaryActions = [],
|
|
options = {}
|
|
) {
|
|
let message = this._l10n.formatValueSync(stringId, { hostname: "<>" });
|
|
|
|
try {
|
|
origin = Services.io.newURI(origin).asciiHost;
|
|
} catch (e) {
|
|
/* Might fail for arbitrary U2F RP IDs. */
|
|
}
|
|
options.name = origin;
|
|
this.show_formatted_msg(
|
|
browsingContext,
|
|
tid,
|
|
id,
|
|
message,
|
|
mainAction,
|
|
secondaryActions,
|
|
options
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Show a PopupNotification instance.
|
|
*
|
|
* @param {CanonicalBrowsingContext} browsingContext
|
|
* @param {number} tid
|
|
* The identifier used by the WebAuthn service.
|
|
* @param {string} id
|
|
* The id to use for the notification.
|
|
* @param {string} message
|
|
* The message to display in the notification.
|
|
* @param {object} mainAction
|
|
* The main button for the notification.
|
|
* @param {Array<object>?} secondaryActions
|
|
* The secondary buttons for the notification.
|
|
* @param {object?} options
|
|
* Additional options for the notification.
|
|
* See PopupNotifications.sys.mjs for more details.
|
|
*/
|
|
show_formatted_msg(
|
|
browsingContext,
|
|
tid,
|
|
id,
|
|
message,
|
|
mainAction,
|
|
secondaryActions = [],
|
|
options = {}
|
|
) {
|
|
this.reset();
|
|
this._tid = tid;
|
|
|
|
// We need to prevent some fullscreen transitions while WebAuthn prompts
|
|
// are shown. The `fullscreen-painted` topic is notified when DOM elements
|
|
// go fullscreen.
|
|
Services.obs.addObserver(this, "fullscreen-painted");
|
|
|
|
// The `fullscreen-nav-toolbox` topic is notified when the nav toolbox is
|
|
// hidden.
|
|
Services.obs.addObserver(this, "fullscreen-nav-toolbox");
|
|
|
|
let chromeWin = browsingContext.topChromeWindow;
|
|
|
|
// Ensure that no DOM elements are already fullscreen.
|
|
chromeWin.FullScreen.exitDomFullScreen();
|
|
|
|
// Ensure that the nav toolbox is being shown.
|
|
if (chromeWin.fullScreen) {
|
|
chromeWin.FullScreen.showNavToolbox();
|
|
}
|
|
|
|
options.hideClose = true;
|
|
options.persistent = true;
|
|
options.eventCallback = event => {
|
|
if (event == "removed") {
|
|
Services.obs.removeObserver(this, "fullscreen-painted");
|
|
Services.obs.removeObserver(this, "fullscreen-nav-toolbox");
|
|
this._current = null;
|
|
this._tid = 0;
|
|
}
|
|
};
|
|
|
|
this._current = chromeWin.PopupNotifications.show(
|
|
browsingContext.top.embedderElement,
|
|
`webauthn-prompt-${id}`,
|
|
message,
|
|
this._icon,
|
|
mainAction,
|
|
secondaryActions,
|
|
options
|
|
);
|
|
},
|
|
|
|
cancel({ tid }) {
|
|
if (this._tid == tid) {
|
|
this.reset();
|
|
}
|
|
},
|
|
|
|
reset() {
|
|
if (this._current) {
|
|
this._current.remove();
|
|
}
|
|
},
|
|
|
|
buildCancelAction(tid) {
|
|
let [cancelMsg] = this._l10n.formatMessagesSync([
|
|
{ id: "webauthn-cancel" },
|
|
]);
|
|
return {
|
|
label: cancelMsg.value,
|
|
accessKey: cancelMsg.attributes.find(a => a.name == "accesskey").value,
|
|
callback() {
|
|
lazy.webauthnService.cancel(tid);
|
|
},
|
|
};
|
|
},
|
|
};
|