// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview 'settings-security-keys-set-pin-dialog' is a dialog for
* setting and changing security key PINs.
*/
import 'chrome://resources/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/cr_elements/cr_dialog/cr_dialog.js';
import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/cr_elements/cr_icons.css.js';
import 'chrome://resources/cr_elements/cr_input/cr_input.js';
import 'chrome://resources/cr_elements/cr_page_selector/cr_page_selector.js';
import 'chrome://resources/polymer/v3_0/paper-spinner/paper-spinner-lite.js';
import '../settings_shared.css.js';
import '../i18n_setup.js';
import type {CrButtonElement} from 'chrome://resources/cr_elements/cr_button/cr_button.js';
import type {CrDialogElement} from 'chrome://resources/cr_elements/cr_dialog/cr_dialog.js';
import type {CrInputElement} from 'chrome://resources/cr_elements/cr_input/cr_input.js';
import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js';
import {PluralStringProxyImpl} from 'chrome://resources/js/plural_string_proxy.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import type {SecurityKeysPinBrowserProxy} from './security_keys_browser_proxy.js';
import {SecurityKeysPinBrowserProxyImpl} from './security_keys_browser_proxy.js';
import {getTemplate} from './security_keys_set_pin_dialog.html.js';
export enum SetPinDialogPage {
INITIAL = 'initial',
NO_PIN_SUPPORT = 'noPINSupport',
REINSERT = 'reinsert',
LOCKED = 'locked',
ERROR = 'error',
PIN_PROMPT = 'pinPrompt',
SUCCESS = 'success',
}
export interface SettingsSecurityKeysSetPinDialogElement {
$: {
closeButton: CrButtonElement,
confirmPIN: CrInputElement,
currentPIN: CrInputElement,
currentPINEntry: HTMLElement,
dialog: CrDialogElement,
error: HTMLElement,
newPIN: CrInputElement,
pinSubmit: CrButtonElement,
};
}
const SettingsSecurityKeysSetPinDialogElementBase = I18nMixin(PolymerElement);
export class SettingsSecurityKeysSetPinDialogElement extends
SettingsSecurityKeysSetPinDialogElementBase {
static get is() {
return 'settings-security-keys-set-pin-dialog';
}
static get template() {
return getTemplate();
}
static get properties() {
return {
/**
* Whether the value of the current PIN textbox is a valid PIN or not.
*/
currentPINValid_: Boolean,
newPINValid_: Boolean,
confirmPINValid_: Boolean,
/**
* Whether the dialog is in a state where the Set PIN button should be
* enabled. Read by Polymer.
*/
setPINButtonValid_: {
type: Boolean,
value: false,
},
/**
* The value of the new PIN textbox. Read/write by Polymer.
*/
newPIN_: {
type: String,
value: '',
},
confirmPIN_: {
type: String,
value: '',
},
currentPIN_: {
type: String,
value: '',
},
/**
* The minimum length for the currently set PIN.
*/
currentMinPinLength_: Number,
/**
* The minimum length to set a new PIN.
*/
newMinPinLength_: {
type: Number,
observer: 'newMinPinLengthChanged_',
},
/**
* The number of PIN attempts remaining.
*/
retries_: Number,
/**
* A CTAP error code when we don't recognise the specific error. Read by
* Polymer.
*/
errorCode_: Number,
/**
* Whether an entry for the current PIN should be displayed. (If no PIN
* has been set then it won't be shown.)
*/
showCurrentEntry_: {
type: Boolean,
value: false,
},
/**
* Error string to display under the current PIN entry, or empty.
*/
currentPINError_: {
type: String,
value: '',
},
/**
* Error string to display under the new PIN entry, or empty.
*/
newPINError_: {
type: String,
value: '',
},
/**
* Error string to display under the confirmation PIN entry, or empty.
*/
confirmPINError_: {
type: String,
value: '',
},
/**
* Whether the dialog process has completed, successfully or otherwise.
*/
complete_: {
type: Boolean,
value: false,
},
/**
* The id of an element on the page that is currently shown.
*/
shown_: {
type: String,
value: SetPinDialogPage.INITIAL,
},
/**
* Whether the contents of the PIN entries are visible, or are displayed
* like passwords.
*/
pinsVisible_: {
type: Boolean,
value: false,
},
title_: String,
newPINDialogDescription_: String,
};
}
private currentPINValid_: boolean;
private newPINValid_: boolean;
private confirmPINValid_: boolean;
private setPINButtonValid_: boolean;
private newPIN_: string;
private confirmPIN_: string;
private currentPIN_: string;
private currentMinPinLength_?: number;
private newMinPinLength_?: number;
private retries_?: number;
private errorCode_?: number;
private showCurrentEntry_: boolean;
private currentPINError_: string;
private newPINError_: string;
private confirmPINError_: string;
private complete_: boolean;
private shown_: SetPinDialogPage;
private pinsVisible_: boolean;
private title_: string;
private newPINDialogDescription_: string;
private browserProxy_: SecurityKeysPinBrowserProxy =
SecurityKeysPinBrowserProxyImpl.getInstance();
override connectedCallback() {
super.connectedCallback();
this.title_ = this.i18n('securityKeysSetPINInitialTitle');
this.$.dialog.showModal();
this.browserProxy_.startSetPin().then(
({done, error, currentMinPinLength, newMinPinLength, retries}) => {
if (done) {
// Operation is complete. error is a CTAP error code. See
// https://fidoalliance.org/specs/fido-v2.0-rd-20180702/fido-client-to-authenticator-protocol-v2.0-rd-20180702.html#error-responses
if (error === 1 /* INVALID_COMMAND */) {
this.shown_ = SetPinDialogPage.NO_PIN_SUPPORT;
this.finish_();
} else if (error === 52 /* temporarily locked */) {
this.shown_ = SetPinDialogPage.REINSERT;
this.finish_();
} else if (error === 50 /* locked */) {
this.shown_ = SetPinDialogPage.LOCKED;
this.finish_();
} else {
this.errorCode_ = error;
this.shown_ = SetPinDialogPage.ERROR;
this.finish_();
}
} else if (retries === 0) {
// A device can also signal that it is locked by returning zero
// retries.
this.shown_ = SetPinDialogPage.LOCKED;
this.finish_();
} else {
// Need to prompt for a pin. Initially set the text boxes to valid
// so that they don't all appear red without the user typing
// anything.
this.currentPINValid_ = true;
this.newPINValid_ = true;
this.confirmPINValid_ = true;
this.setPINButtonValid_ = true;
this.currentMinPinLength_ = currentMinPinLength;
this.newMinPinLength_ = newMinPinLength;
this.retries_ = retries;
// retries_ may be null to indicate that there is currently no PIN
// set.
let focusTarget: HTMLElement;
if (this.retries_ === null) {
this.showCurrentEntry_ = false;
focusTarget = this.$.newPIN;
this.title_ = this.i18n('securityKeysSetPINCreateTitle');
} else {
this.showCurrentEntry_ = true;
focusTarget = this.$.currentPIN;
this.title_ = this.i18n('securityKeysSetPINChangeTitle');
}
this.shown_ = SetPinDialogPage.PIN_PROMPT;
// Focus cannot be set directly from within a backend callback.
window.setTimeout(function() {
focusTarget.focus();
}, 0);
this.fire_('ui-ready'); // for test synchronization.
}
});
}
private fire_(eventName: string, detail?: any) {
this.dispatchEvent(
new CustomEvent(eventName, {bubbles: true, composed: true, detail}));
}
private closeDialog_() {
this.$.dialog.close();
this.finish_();
}
private finish_() {
if (this.complete_) {
return;
}
this.complete_ = true;
// Setting |complete_| to true hides the |pinSubmitNew| button while it
// has focus, which in turn causes the browser to move focus to the <body>
// element, which in turn prevents subsequent "Enter" keystrokes to be
// handled by cr-dialog itself. Re-focusing manually fixes this.
this.$.dialog.focus();
this.browserProxy_.close();
}
private onIronSelect_(e: Event) {
// Prevent this event from bubbling since it is unnecessarily triggering
// the listener within settings-animated-pages.
e.stopPropagation();
}
private onCurrentPinInput_() {
// Typing in the current PIN box after an error makes the error message
// disappear.
this.currentPINError_ = '';
}
private onNewPinInput_() {
// Typing in the new PIN box after an error makes the error message
// disappear.
this.newPINError_ = '';
}
private onConfirmPinInput_() {
// Typing in the confirm PIN box after an error makes the error message
// disappear.
this.confirmPINError_ = '';
}
/**
@param pin A candidate PIN.
@return An error string or else '' to indicate validity.
*/
private isValidPin_(pin: string, minLength: number): string {
// The UTF-8 encoding of the PIN must be between minLength and 63 bytes, and
// the final byte cannot be zero.
const utf8Encoded = new TextEncoder().encode(pin);
if (utf8Encoded.length < minLength) {
return this.i18n('securityKeysPINTooShort');
}
if (utf8Encoded.length > 63 ||
// If the PIN somehow has a NUL at the end then it's invalid, but this
// is so obscure that we don't try to message it. Rather we just say
// that it's too long because trimming the final character is the best
// response by the user.
utf8Encoded[utf8Encoded.length - 1] === 0) {
return this.i18n('securityKeysPINTooLong');
}
// A PIN must contain at least four code-points. Javascript strings are
// UCS-2 and the |length| property counts UCS-2 elements, not code-points.
// (For example, '\u{1f6b4}'.length === 2, but it's a single code-point.)
// Therefore, iterate over the string (which does yield codepoints) and
// check that |minLength| or more were seen.
let length = 0;
for (const _codepoint of pin) {
length++;
}
if (length < minLength) {
return this.i18n('securityKeysPINTooShort');
}
return '';
}
/**
* @param retries The number of PIN attempts remaining.
* @return The message to show under the text box.
*/
private mismatchError_(retries: number): string {
// Warn the user if the number of retries is getting low.
if (1 < retries && retries <= 3) {
return this.i18n('securityKeysPINIncorrectRetriesPl', retries.toString());
}
if (retries === 1) {
return this.i18n('securityKeysPINIncorrectRetriesSin');
}
return this.i18n('securityKeysPINIncorrect');
}
/**
* Called to set focus from inside a callback.
*/
private focusOn_(focusTarget: HTMLElement) {
// Focus cannot be set directly from within a backend callback. Also,
// directly focusing |currentPIN| doesn't always seem to work(!). Thus
// focus something else first, which is a hack that seems to solve the
// problem.
let preFocusTarget = this.$.newPIN;
if (preFocusTarget === focusTarget) {
preFocusTarget = this.$.currentPIN;
}
window.setTimeout(function() {
preFocusTarget.focus();
focusTarget.focus();
}, 0);
}
/**
* Called by Polymer when the Set PIN button is activated.
*/
private pinSubmitNew_() {
if (this.showCurrentEntry_) {
this.currentPINError_ =
this.isValidPin_(this.currentPIN_, this.currentMinPinLength_!);
if (this.currentPINError_ !== '') {
this.focusOn_(this.$.currentPIN);
this.fire_('ui-ready'); // for test synchronization.
return;
}
}
this.newPINError_ = this.isValidPin_(this.newPIN_, this.newMinPinLength_!);
if (this.newPINError_ !== '') {
this.focusOn_(this.$.newPIN);
this.fire_('ui-ready'); // for test synchronization.
return;
}
if (this.newPIN_ !== this.confirmPIN_) {
this.confirmPINError_ = this.i18n('securityKeysPINMismatch');
this.focusOn_(this.$.confirmPIN);
this.fire_('ui-ready'); // for test synchronization.
return;
}
if (this.newPIN_ === this.currentPIN_) {
this.newPINError_ = this.i18n('securityKeysSamePINAsCurrent');
this.focusOn_(this.$.newPIN);
this.fire_('ui-ready'); // for test synchronization.
return;
}
this.setPINButtonValid_ = false;
this.browserProxy_.setPin(this.currentPIN_, this.newPIN_).then(response => {
const error = response.error;
// This call always completes the process so response.done is always
// true. error is a CTAP2 error code. See
// https://fidoalliance.org/specs/fido-v2.0-rd-20180702/fido-client-to-authenticator-protocol-v2.0-rd-20180702.html#error-responses
if (error === 0 /* SUCCESS */) {
this.shown_ = SetPinDialogPage.SUCCESS;
this.finish_();
} else if (error === 52 /* temporarily locked */) {
this.shown_ = SetPinDialogPage.REINSERT;
this.finish_();
} else if (error === 50 /* locked */) {
this.shown_ = SetPinDialogPage.LOCKED;
this.finish_();
} else if (error === 49 /* PIN_INVALID */) {
this.currentPINValid_ = false;
this.retries_!--;
this.currentPINError_ = this.mismatchError_(this.retries_!);
this.setPINButtonValid_ = true;
this.focusOn_(this.$.currentPIN);
this.fire_('ui-ready'); // for test synchronization.
} else {
// Unknown error.
this.errorCode_ = error;
this.shown_ = SetPinDialogPage.ERROR;
this.finish_();
}
});
}
/**
* onClick handler for the show/hide icon.
*/
private showPinsClick_() {
this.pinsVisible_ = !this.pinsVisible_;
}
/**
* Polymer helper function to detect when an error string is empty.
*/
private isNonEmpty_(s: string): boolean {
return s !== '';
}
/**
* Called by Polymer when |errorCode_| changes to set the error string.
*/
private pinFailed_() {
if (this.errorCode_ === null) {
return '';
}
return this.i18n('securityKeysPINError', this.errorCode_!.toString());
}
/**
* @return The class of the Ok / Cancel button.
*/
private maybeActionButton_(): string {
return this.complete_ ? 'action-button' : 'cancel-button';
}
/**
* @return The label of the Ok / Cancel button.
*/
private closeText_(): string {
return this.i18n(this.complete_ ? 'ok' : 'cancel');
}
private newMinPinLengthChanged_() {
PluralStringProxyImpl.getInstance()
.getPluralString('securityKeysNewPIN', this.newMinPinLength_!)
.then(string => this.newPINDialogDescription_ = string);
}
/**
* @return The class (and thus icon) to be displayed.
*/
private showPinsClass_(): string {
return 'icon-visibility' + (this.pinsVisible_ ? '-off' : '');
}
/**
* @return The tooltip for the icon.
*/
private showPinsTitle_(): string {
return this.i18n(
this.pinsVisible_ ? 'securityKeysHidePINs' : 'securityKeysShowPINs');
}
/**
* @return The PIN-input element type.
*/
private inputType_(): string {
return this.pinsVisible_ ? 'text' : 'password';
}
}
declare global {
interface HTMLElementTagNameMap {
'settings-security-keys-set-pin-dialog':
SettingsSecurityKeysSetPinDialogElement;
}
}
customElements.define(
SettingsSecurityKeysSetPinDialogElement.is,
SettingsSecurityKeysSetPinDialogElement);