chrome-devtools-frontend
Version:
Chrome DevTools UI
926 lines (827 loc) • 39.5 kB
text/typescript
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable rulesdir/no-imperative-dom-api */
import '../../ui/legacy/legacy.js';
import * as Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import * as i18n from '../../core/i18n/i18n.js';
import type * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Protocol from '../../generated/protocol.js';
import * as Buttons from '../../ui/components/buttons/buttons.js';
import * as DataGrid from '../../ui/legacy/components/data_grid/data_grid.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as Lit from '../../ui/lit/lit.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import webauthnPaneStyles from './webauthnPane.css.js';
const {render, html} = Lit;
const UIStrings = {
/**
*@description Label for button that allows user to download the private key related to a credential.
*/
export: 'Export',
/**
*@description Label for an item to remove something
*/
remove: 'Remove',
/**
*@description Label for empty credentials table.
*@example {navigator.credentials.create()} PH1
*/
noCredentialsTryCallingSFromYour: 'No credentials. Try calling {PH1} from your website.',
/**
*@description Label for checkbox to toggle the virtual authenticator environment allowing user to interact with software-based virtual authenticators.
*/
enableVirtualAuthenticator: 'Enable virtual authenticator environment',
/**
*@description Label for ID field for credentials.
*/
id: 'ID',
/**
*@description Label for field that describes whether a credential is a resident credential.
*/
isResident: 'Is Resident',
/**
*@description Label for credential field that represents the Relying Party ID that the credential is scoped to.
*/
rpId: 'RP ID',
/**
*@description Label for a column in a table. A field/unique ID that represents the user a credential is mapped to.
*/
userHandle: 'User Handle',
/**
*@description Label for signature counter field for credentials which represents the number of successful assertions.
* See https://w3c.github.io/webauthn/#signature-counter.
*/
signCount: 'Signature Count',
/**
*@description Label for column with actions for credentials.
*/
actions: 'Actions',
/**
*@description Title for the table that holds the credentials that a authenticator has registered.
*/
credentials: 'Credentials',
/**
*@description Text that shows before the virtual environment is enabled.
*/
noAuthenticator: 'No authenticator set up',
/**
*@description That that shows before virtual environment is enabled explaining the panel.
*/
useWebauthnForPhishingresistant: 'Use WebAuthn for phishing-resistant authentication.',
/**
*@description Title for section of interface that allows user to add a new virtual authenticator.
*/
newAuthenticator: 'New authenticator',
/**
*@description Text for security or network protocol
*/
protocol: 'Protocol',
/**
*@description Label for input to select which transport option to use on virtual authenticators, e.g. USB or Bluetooth.
*/
transport: 'Transport',
/**
*@description Label for checkbox that toggles resident key support on virtual authenticators.
*/
supportsResidentKeys: 'Supports resident keys',
/**
*@description Label for checkbox that toggles large blob support on virtual authenticators. Large blobs are opaque data associated
* with a WebAuthn credential that a website can store, like an SSH certificate or a symmetric encryption key.
* See https://w3c.github.io/webauthn/#sctn-large-blob-extension
*/
supportsLargeBlob: 'Supports large blob',
/**
*@description Text to add something
*/
add: 'Add',
/**
*@description Label for button to add a new virtual authenticator.
*/
addAuthenticator: 'Add authenticator',
/**
*@description Label for radio button that toggles whether an authenticator is active.
*/
active: 'Active',
/**
*@description Title for button that enables user to customize name of authenticator.
*/
editName: 'Edit name',
/**
*@description Placeholder for the input box to customize name of authenticator.
*/
enterNewName: 'Enter new name',
/**
*@description Title for button that enables user to save name of authenticator after editing it.
*/
saveName: 'Save name',
/**
*@description Title for a user-added virtual authenticator which is uniquely identified with its AUTHENTICATORID.
*@example {8c7873be-0b13-4996-a794-1521331bbd96} PH1
*/
authenticatorS: 'Authenticator {PH1}',
/**
*@description Name for generated file which user can download. A private key is a secret code which enables encoding and decoding of a credential. .pem is the file extension.
*/
privateKeypem: 'Private key.pem',
/**
*@description Label for field that holds an authenticator's universally unique identifier (UUID).
*/
uuid: 'UUID',
/**
*@description Label for checkbox that toggles user verification support on virtual authenticators.
*/
supportsUserVerification: 'Supports user verification',
/**
*@description Text in Timeline indicating that input has happened recently
*/
yes: 'Yes',
/**
*@description Text in Timeline indicating that input has not happened recently
*/
no: 'No',
/**
*@description Title of radio button that sets an authenticator as active.
*@example {Authenticator ABCDEF} PH1
*/
setSAsTheActiveAuthenticator: 'Set {PH1} as the active authenticator',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/webauthn/WebauthnPane.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const i18nTemplate = Lit.i18nTemplate.bind(undefined, str_);
const WEB_AUTHN_EXPLANATION_URL =
'https://developer.chrome.com/docs/devtools/webauthn' as Platform.DevToolsPath.UrlString;
class DataGridNode extends DataGrid.DataGrid.DataGridNode<DataGridNode> {
constructor(private readonly credential: Protocol.WebAuthn.Credential) {
super(credential);
}
override nodeSelfHeight(): number {
return 24;
}
override createCell(columnId: string): HTMLElement {
const cell = super.createCell(columnId);
UI.Tooltip.Tooltip.install(cell, cell.textContent || '');
if (columnId !== 'actions') {
return cell;
}
const onExportCredential = (): void => {
if (this.dataGrid) {
(this.dataGrid as WebauthnDataGrid).onExportCredential(this.credential);
}
};
const onRemoveCredential = (): void => {
if (this.dataGrid) {
(this.dataGrid as WebauthnDataGrid).onRemoveCredential(this.credential);
}
};
// clang-format off
// eslint-disable-next-line rulesdir/no-lit-render-outside-of-view
render(html`
<devtools-button .variant=${Buttons.Button.Variant.OUTLINED}
=${onExportCredential} .jslogContext=${'webauthn.export-credential'}>
${i18nString(UIStrings.export)}
</devtools-button>
<devtools-button .variant=${Buttons.Button.Variant.OUTLINED}
=${onRemoveCredential} .jslogContext=${'webauthn.remove-credential'}>
${i18nString(UIStrings.remove)}
</devtools-button>`, cell);
// clang-format on
return cell;
}
}
class WebauthnDataGrid extends DataGrid.DataGrid.DataGridImpl<DataGridNode> {
onExportCredential = (_: Protocol.WebAuthn.Credential): void => {};
onRemoveCredential = (_: Protocol.WebAuthn.Credential): void => {};
}
class EmptyDataGridNode extends DataGrid.DataGrid.DataGridNode<DataGridNode> {
override createCells(element: Element): void {
element.removeChildren();
// clang-format off
// eslint-disable-next-line rulesdir/no-lit-render-outside-of-view
render(html`
<td class=${DataGrid.DataGrid.Align.CENTER} colspan=${this.dataGrid?.visibleColumnsArray.length ?? 1}>
${i18nTemplate(UIStrings.noCredentialsTryCallingSFromYour,
{PH1: html`<span class="code">navigator.credentials.create()</span>`})}
</td>`, element as HTMLElement);
// clang-format on
}
}
type AvailableAuthenticatorOptions = Protocol.WebAuthn.VirtualAuthenticatorOptions&{
active: boolean,
authenticatorId: Protocol.WebAuthn.AuthenticatorId,
};
// We extrapolate this variable as otherwise git detects a private key, even though we
// perform string manipulation. If we extract the name, then the regex doesn't match
// and we can upload as expected.
const PRIVATE_NAME = 'PRIVATE';
const PRIVATE_KEY_HEADER = `-----BEGIN ${PRIVATE_NAME} KEY-----
`;
const PRIVATE_KEY_FOOTER = `-----END ${PRIVATE_NAME} KEY-----`;
const PROTOCOL_AUTHENTICATOR_VALUES: Protocol.EnumerableEnum<typeof Protocol.WebAuthn.AuthenticatorProtocol> = {
Ctap2: Protocol.WebAuthn.AuthenticatorProtocol.Ctap2,
U2f: Protocol.WebAuthn.AuthenticatorProtocol.U2f,
};
export class WebauthnPaneImpl extends UI.Widget.VBox implements
SDK.TargetManager.SDKModelObserver<SDK.WebAuthnModel.WebAuthnModel> {
#activeAuthId: Protocol.WebAuthn.AuthenticatorId|null = null;
#hasBeenEnabled = false;
readonly dataGrids = new Map<Protocol.WebAuthn.AuthenticatorId, DataGrid.DataGrid.DataGridImpl<DataGridNode>>();
#enableCheckbox!: UI.Toolbar.ToolbarCheckbox;
readonly #availableAuthenticatorSetting: Common.Settings.Setting<AvailableAuthenticatorOptions[]>;
#model?: SDK.WebAuthnModel.WebAuthnModel;
#authenticatorsView: HTMLElement;
#topToolbarContainer: HTMLElement|undefined;
#topToolbar: UI.Toolbar.Toolbar|undefined;
#learnMoreView: UI.EmptyWidget.EmptyWidget|undefined;
#newAuthenticatorSection: HTMLElement|undefined;
#newAuthenticatorForm: HTMLElement|undefined;
#protocolSelect: HTMLSelectElement|undefined;
transportSelect: HTMLSelectElement|undefined;
residentKeyCheckbox: HTMLInputElement|undefined;
#userVerificationCheckbox: HTMLInputElement|undefined;
largeBlobCheckbox: HTMLInputElement|undefined;
addAuthenticatorButton: Buttons.Button.Button|undefined;
#isEnabling?: Promise<void>;
constructor() {
super(true);
this.registerRequiredCSS(webauthnPaneStyles);
this.element.setAttribute('jslog', `${VisualLogging.panel('webauthn').track({resize: true})}`);
SDK.TargetManager.TargetManager.instance().observeModels(SDK.WebAuthnModel.WebAuthnModel, this, {scoped: true});
this.contentElement.classList.add('webauthn-pane');
this.#availableAuthenticatorSetting =
Common.Settings.Settings.instance().createSetting<AvailableAuthenticatorOptions[]>(
'webauthn-authenticators', []);
this.#createToolbar();
this.#authenticatorsView = this.contentElement.createChild('div', 'authenticators-view');
this.#createNewAuthenticatorSection();
this.#updateVisibility(false);
}
modelAdded(model: SDK.WebAuthnModel.WebAuthnModel): void {
if (model.target() === model.target().outermostTarget()) {
this.#model = model;
}
}
modelRemoved(model: SDK.WebAuthnModel.WebAuthnModel): void {
if (model.target() === model.target().outermostTarget()) {
this.#model = undefined;
}
}
async #loadInitialAuthenticators(): Promise<void> {
let activeAuthenticatorId: Protocol.WebAuthn.AuthenticatorId|null = null;
const availableAuthenticators = this.#availableAuthenticatorSetting.get();
for (const options of availableAuthenticators) {
if (!this.#model) {
continue;
}
const authenticatorId = await this.#model.addAuthenticator(options);
void this.#addAuthenticatorSection(authenticatorId, options);
// Update the authenticatorIds in the options.
options.authenticatorId = authenticatorId;
if (options.active) {
activeAuthenticatorId = authenticatorId;
}
}
// Update the settings to reflect the new authenticatorIds.
this.#availableAuthenticatorSetting.set(availableAuthenticators);
if (activeAuthenticatorId) {
void this.#setActiveAuthenticator(activeAuthenticatorId);
}
}
override async ownerViewDisposed(): Promise<void> {
if (this.#enableCheckbox) {
this.#enableCheckbox.setChecked(false);
}
await this.#setVirtualAuthEnvEnabled(false);
}
#createToolbar(): void {
this.#topToolbarContainer = this.contentElement.createChild('div', 'webauthn-toolbar-container');
this.#topToolbarContainer.setAttribute('jslog', `${VisualLogging.toolbar()}`);
this.#topToolbarContainer.role = 'toolbar';
this.#topToolbar = this.#topToolbarContainer.createChild('devtools-toolbar', 'webauthn-toolbar');
this.#topToolbar.role = 'presentation';
const enableCheckboxTitle = i18nString(UIStrings.enableVirtualAuthenticator);
this.#enableCheckbox = new UI.Toolbar.ToolbarCheckbox(
enableCheckboxTitle, enableCheckboxTitle, this.#handleCheckboxToggle.bind(this), 'virtual-authenticators');
this.#topToolbar.appendToolbarItem(this.#enableCheckbox);
}
#createCredentialsDataGrid(authenticatorId: Protocol.WebAuthn.AuthenticatorId):
DataGrid.DataGrid.DataGridImpl<DataGridNode> {
const columns = ([
{
id: 'credentialId',
title: i18nString(UIStrings.id),
longText: true,
weight: 24,
},
{
id: 'isResidentCredential',
title: i18nString(UIStrings.isResident),
dataType: DataGrid.DataGrid.DataType.BOOLEAN,
weight: 10,
},
{
id: 'rpId',
title: i18nString(UIStrings.rpId),
},
{
id: 'userHandle',
title: i18nString(UIStrings.userHandle),
},
{
id: 'signCount',
title: i18nString(UIStrings.signCount),
},
{id: 'actions', title: i18nString(UIStrings.actions)},
] as DataGrid.DataGrid.ColumnDescriptor[]);
const dataGridConfig = {
displayName: i18nString(UIStrings.credentials),
columns,
editCallback: undefined,
deleteCallback: undefined,
refreshCallback: undefined,
};
const dataGrid = new WebauthnDataGrid(dataGridConfig);
dataGrid.renderInline();
dataGrid.setStriped(true);
dataGrid.onExportCredential = this.#exportCredential.bind(this);
dataGrid.onRemoveCredential = ({credentialId}) => this.#removeCredential(authenticatorId, credentialId);
dataGrid.rootNode().appendChild(new EmptyDataGridNode());
this.dataGrids.set(authenticatorId, dataGrid);
return dataGrid;
}
#addCredential(authenticatorId: Protocol.WebAuthn.AuthenticatorId, {
data: event,
}: Common.EventTarget.EventTargetEvent<Protocol.WebAuthn.CredentialAddedEvent>): void {
const dataGrid = this.dataGrids.get(authenticatorId);
if (!dataGrid) {
return;
}
const emptyNode = dataGrid.rootNode().children.find(node => !Object.keys(node.data).length);
if (emptyNode) {
dataGrid.rootNode().removeChild(emptyNode);
}
const node = new DataGridNode(event.credential);
dataGrid.rootNode().appendChild(node);
}
#updateCredential(
authenticatorId: Protocol.WebAuthn.AuthenticatorId,
{
data: event,
}: Common.EventTarget
.EventTargetEvent<Protocol.WebAuthn.CredentialAssertedEvent&Protocol.WebAuthn.CredentialUpdatedEvent>): void {
const dataGrid = this.dataGrids.get(authenticatorId);
if (!dataGrid) {
return;
}
const node = dataGrid.rootNode().children.find(node => node.data?.credentialId === event.credential.credentialId);
if (!node) {
return;
}
node.data = event.credential;
}
#deleteCredential(authenticatorId: Protocol.WebAuthn.AuthenticatorId, {
data: event,
}: Common.EventTarget.EventTargetEvent<Protocol.WebAuthn.CredentialDeletedEvent>): void {
const dataGrid = this.dataGrids.get(authenticatorId);
if (!dataGrid) {
return;
}
const node = dataGrid.rootNode().children.find(node => node.data?.credentialId === event.credentialId);
if (!node) {
return;
}
node.remove();
}
async #setVirtualAuthEnvEnabled(enable: boolean): Promise<void> {
await this.#isEnabling;
this.#isEnabling = new Promise<void>(async (resolve: (value: void) => void) => {
if (enable && !this.#hasBeenEnabled) {
// Ensures metric is only tracked once per session.
Host.userMetrics.actionTaken(Host.UserMetrics.Action.VirtualAuthenticatorEnvironmentEnabled);
this.#hasBeenEnabled = true;
}
if (this.#model) {
await this.#model.setVirtualAuthEnvEnabled(enable);
}
if (enable) {
await this.#loadInitialAuthenticators();
} else {
this.#removeAuthenticatorSections();
}
this.#updateVisibility(enable);
this.#isEnabling = undefined;
resolve();
});
}
#updateVisibility(enabled: boolean): void {
this.contentElement.classList.toggle('enabled', enabled);
}
#removeAuthenticatorSections(): void {
this.#authenticatorsView.innerHTML = '';
for (const dataGrid of this.dataGrids.values()) {
dataGrid.asWidget().detach();
}
this.dataGrids.clear();
}
#handleCheckboxToggle(e: MouseEvent): void {
void this.#setVirtualAuthEnvEnabled((e.target as HTMLInputElement).checked);
}
#updateEnabledTransportOptions(enabledOptions: Protocol.WebAuthn.AuthenticatorTransport[]): void {
if (!this.transportSelect) {
return;
}
const prevValue = this.transportSelect.value;
this.transportSelect.removeChildren();
for (const option of enabledOptions) {
this.transportSelect.appendChild(UI.UIUtils.createOption(option, option, option));
}
// Make sure the currently selected value stays the same.
this.transportSelect.value = prevValue;
// If the new set does not include the previous value.
if (!this.transportSelect.value) {
// Select the first available value.
this.transportSelect.selectedIndex = 0;
}
this.#updateInternalTransportAvailability();
}
#updateInternalTransportAvailability(): void {
if (!this.transportSelect?.options) {
return;
}
const hasInternal = Boolean(this.#availableAuthenticatorSetting.get().find(
authenticator => authenticator.transport === Protocol.WebAuthn.AuthenticatorTransport.Internal));
for (let i = 0; i < this.transportSelect.options.length; ++i) {
const option = this.transportSelect.options[i];
if (option.value === Protocol.WebAuthn.AuthenticatorTransport.Internal) {
option.disabled = hasInternal;
// This relies on "internal" never being the first or only element.
if (i === this.transportSelect.selectedIndex) {
--this.transportSelect.selectedIndex;
}
break;
}
}
}
#updateNewAuthenticatorSectionOptions(): void {
if (!this.#protocolSelect || !this.residentKeyCheckbox || !this.#userVerificationCheckbox ||
!this.largeBlobCheckbox) {
return;
}
if (this.#protocolSelect.value === Protocol.WebAuthn.AuthenticatorProtocol.Ctap2) {
this.residentKeyCheckbox.disabled = false;
this.#userVerificationCheckbox.disabled = false;
this.largeBlobCheckbox.disabled = !this.residentKeyCheckbox.checked;
if (this.largeBlobCheckbox.disabled) {
this.largeBlobCheckbox.checked = false;
}
this.#updateEnabledTransportOptions([
Protocol.WebAuthn.AuthenticatorTransport.Usb,
Protocol.WebAuthn.AuthenticatorTransport.Ble,
Protocol.WebAuthn.AuthenticatorTransport.Nfc,
Protocol.WebAuthn.AuthenticatorTransport.Internal,
]);
} else {
this.residentKeyCheckbox.checked = false;
this.residentKeyCheckbox.disabled = true;
this.#userVerificationCheckbox.checked = false;
this.#userVerificationCheckbox.disabled = true;
this.largeBlobCheckbox.checked = false;
this.largeBlobCheckbox.disabled = true;
this.#updateEnabledTransportOptions([
Protocol.WebAuthn.AuthenticatorTransport.Usb,
Protocol.WebAuthn.AuthenticatorTransport.Ble,
Protocol.WebAuthn.AuthenticatorTransport.Nfc,
]);
}
}
#createNewAuthenticatorSection(): void {
this.#learnMoreView = new UI.EmptyWidget.EmptyWidget(
i18nString(UIStrings.noAuthenticator), i18nString(UIStrings.useWebauthnForPhishingresistant));
this.#learnMoreView.element.classList.add('learn-more');
this.#learnMoreView.link = WEB_AUTHN_EXPLANATION_URL;
this.#learnMoreView.show(this.contentElement);
this.#newAuthenticatorSection = this.contentElement.createChild('div', 'new-authenticator-container');
const newAuthenticatorTitle =
UI.UIUtils.createLabel(i18nString(UIStrings.newAuthenticator), 'new-authenticator-title');
this.#newAuthenticatorSection.appendChild(newAuthenticatorTitle);
this.#newAuthenticatorForm = this.#newAuthenticatorSection.createChild('div', 'new-authenticator-form');
this.#newAuthenticatorForm.setAttribute('jslog', `${VisualLogging.section('new-authenticator')}`);
const protocolGroup = this.#newAuthenticatorForm.createChild('div', 'authenticator-option');
const transportGroup = this.#newAuthenticatorForm.createChild('div', 'authenticator-option');
const residentKeyGroup = this.#newAuthenticatorForm.createChild('div', 'authenticator-option');
const userVerificationGroup = this.#newAuthenticatorForm.createChild('div', 'authenticator-option');
const largeBlobGroup = this.#newAuthenticatorForm.createChild('div', 'authenticator-option');
const addButtonGroup = this.#newAuthenticatorForm.createChild('div', 'authenticator-option');
const protocolSelectTitle = UI.UIUtils.createLabel(i18nString(UIStrings.protocol), 'authenticator-option-label');
protocolGroup.appendChild(protocolSelectTitle);
this.#protocolSelect = protocolGroup.createChild('select');
this.#protocolSelect.setAttribute('jslog', `${VisualLogging.dropDown('protocol').track({change: true})}`);
UI.ARIAUtils.bindLabelToControl(protocolSelectTitle, (this.#protocolSelect as Element));
Object.values(PROTOCOL_AUTHENTICATOR_VALUES).sort().forEach((option: Protocol.WebAuthn.AuthenticatorProtocol) => {
if (this.#protocolSelect) {
this.#protocolSelect.appendChild(UI.UIUtils.createOption(option, option, option));
}
});
if (this.#protocolSelect) {
this.#protocolSelect.value = Protocol.WebAuthn.AuthenticatorProtocol.Ctap2;
}
const transportSelectTitle = UI.UIUtils.createLabel(i18nString(UIStrings.transport), 'authenticator-option-label');
transportGroup.appendChild(transportSelectTitle);
this.transportSelect = transportGroup.createChild('select');
this.transportSelect.setAttribute('jslog', `${VisualLogging.dropDown('transport').track({change: true})}`);
UI.ARIAUtils.bindLabelToControl(transportSelectTitle, (this.transportSelect as Element));
// transportSelect will be populated in updateNewAuthenticatorSectionOptions.
const residentKeyCheckboxLabel =
UI.UIUtils.createLabel(i18nString(UIStrings.supportsResidentKeys), 'authenticator-option-label');
residentKeyCheckboxLabel.setAttribute('jslog', `${VisualLogging.toggle('resident-key').track({change: true})}`);
residentKeyGroup.appendChild(residentKeyCheckboxLabel);
this.residentKeyCheckbox = residentKeyGroup.createChild('input', 'authenticator-option-checkbox');
this.residentKeyCheckbox.type = 'checkbox';
this.residentKeyCheckbox.checked = false;
UI.ARIAUtils.bindLabelToControl(residentKeyCheckboxLabel, this.residentKeyCheckbox);
const userVerificationCheckboxLabel =
UI.UIUtils.createLabel(i18nString(UIStrings.supportsUserVerification), 'authenticator-option-label');
userVerificationCheckboxLabel.setAttribute(
'jslog', `${VisualLogging.toggle('user-verification').track({change: true})}`);
userVerificationGroup.appendChild(userVerificationCheckboxLabel);
this.#userVerificationCheckbox = userVerificationGroup.createChild('input', 'authenticator-option-checkbox');
this.#userVerificationCheckbox.type = 'checkbox';
this.#userVerificationCheckbox.checked = false;
UI.ARIAUtils.bindLabelToControl(userVerificationCheckboxLabel, this.#userVerificationCheckbox);
const largeBlobCheckboxLabel =
UI.UIUtils.createLabel(i18nString(UIStrings.supportsLargeBlob), 'authenticator-option-label');
largeBlobCheckboxLabel.setAttribute('jslog', `${VisualLogging.toggle('large-blob').track({change: true})}`);
largeBlobGroup.appendChild(largeBlobCheckboxLabel);
this.largeBlobCheckbox = largeBlobGroup.createChild('input', 'authenticator-option-checkbox');
this.largeBlobCheckbox.type = 'checkbox';
this.largeBlobCheckbox.checked = false;
UI.ARIAUtils.bindLabelToControl(largeBlobCheckboxLabel, this.largeBlobCheckbox);
this.addAuthenticatorButton = UI.UIUtils.createTextButton(
i18nString(UIStrings.add), this.#handleAddAuthenticatorButton.bind(this),
{jslogContext: 'webauthn.add-authenticator'});
addButtonGroup.createChild('div', 'authenticator-option-label');
addButtonGroup.appendChild(this.addAuthenticatorButton);
const addAuthenticatorTitle = UI.UIUtils.createLabel(i18nString(UIStrings.addAuthenticator), '');
UI.ARIAUtils.bindLabelToControl(addAuthenticatorTitle, this.addAuthenticatorButton);
this.#updateNewAuthenticatorSectionOptions();
if (this.#protocolSelect) {
this.#protocolSelect.addEventListener('change', this.#updateNewAuthenticatorSectionOptions.bind(this));
}
if (this.residentKeyCheckbox) {
this.residentKeyCheckbox.addEventListener('change', this.#updateNewAuthenticatorSectionOptions.bind(this));
}
}
async #handleAddAuthenticatorButton(): Promise<void> {
const options = this.#createOptionsFromCurrentInputs();
if (this.#model) {
const authenticatorId = await this.#model.addAuthenticator(options);
const availableAuthenticators = this.#availableAuthenticatorSetting.get();
availableAuthenticators.push({authenticatorId, active: true, ...options});
this.#availableAuthenticatorSetting.set(
availableAuthenticators.map(a => ({...a, active: a.authenticatorId === authenticatorId})));
const section = await this.#addAuthenticatorSection(authenticatorId, options);
const mediaQueryList = window.matchMedia('(prefers-reduced-motion: reduce)');
const prefersReducedMotion = mediaQueryList.matches;
section.scrollIntoView({block: 'start', behavior: prefersReducedMotion ? 'auto' : 'smooth'});
this.#updateInternalTransportAvailability();
}
}
async #addAuthenticatorSection(
authenticatorId: Protocol.WebAuthn.AuthenticatorId,
options: Protocol.WebAuthn.VirtualAuthenticatorOptions): Promise<HTMLDivElement> {
const section = document.createElement('div');
section.classList.add('authenticator-section');
section.setAttribute('data-authenticator-id', authenticatorId);
section.setAttribute('jslog', `${VisualLogging.section('authenticator')}`);
this.#authenticatorsView.appendChild(section);
const headerElement = section.createChild('div', 'authenticator-section-header');
const titleElement = headerElement.createChild('div', 'authenticator-section-title');
UI.ARIAUtils.markAsHeading(titleElement, 2);
await this.#clearActiveAuthenticator();
const activeButtonContainer = headerElement.createChild('div', 'active-button-container');
const {label: activeLabel, radio: activeRadio} = UI.UIUtils.createRadioButton(
'active-authenticator', i18nString(UIStrings.active), 'webauthn.active-authenticator');
activeRadio.addEventListener('change', this.#setActiveAuthenticator.bind(this, authenticatorId));
activeRadio.checked = true;
activeButtonContainer.appendChild(activeLabel);
this.#activeAuthId = authenticatorId; // Newly added authenticator is automatically set as active.
const removeButton = headerElement.createChild('button', 'text-button');
removeButton.textContent = i18nString(UIStrings.remove);
removeButton.addEventListener('click', this.removeAuthenticator.bind(this, authenticatorId));
removeButton.setAttribute('jslog', `${VisualLogging.action('webauthn.remove-authenticator').track({click: true})}`);
const toolbar = titleElement.createChild('devtools-toolbar', 'edit-name-toolbar');
const editName = new UI.Toolbar.ToolbarButton(i18nString(UIStrings.editName), 'edit', undefined, 'edit-name');
const saveName = new UI.Toolbar.ToolbarButton(i18nString(UIStrings.saveName), 'checkmark', undefined, 'save-name');
saveName.setVisible(false);
const nameField = titleElement.createChild('input', 'authenticator-name-field');
nameField.placeholder = i18nString(UIStrings.enterNewName);
nameField.disabled = true;
nameField.setAttribute('jslog', `${VisualLogging.textField('name').track({keydown: 'Enter', change: true})}`);
const userFriendlyName = authenticatorId.slice(-5); // User friendly name defaults to last 5 chars of UUID.
nameField.value = i18nString(UIStrings.authenticatorS, {PH1: userFriendlyName});
this.#updateActiveLabelTitle(activeLabel, nameField.value);
editName.addEventListener(
UI.Toolbar.ToolbarButton.Events.CLICK,
() => this.#handleEditNameButton(titleElement, nameField, editName, saveName));
saveName.addEventListener(
UI.Toolbar.ToolbarButton.Events.CLICK,
() => this.#handleSaveNameButton(titleElement, nameField, editName, saveName, activeLabel));
nameField.addEventListener(
'focusout', () => this.#handleSaveNameButton(titleElement, nameField, editName, saveName, activeLabel));
nameField.addEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === 'Enter') {
this.#handleSaveNameButton(titleElement, nameField, editName, saveName, activeLabel);
}
});
toolbar.appendToolbarItem(editName);
toolbar.appendToolbarItem(saveName);
this.#createAuthenticatorFields(section, authenticatorId, options);
const label = document.createElement('div');
label.classList.add('credentials-title');
label.textContent = i18nString(UIStrings.credentials);
section.appendChild(label);
const dataGrid = this.#createCredentialsDataGrid(authenticatorId);
dataGrid.asWidget().show(section);
if (this.#model) {
this.#model.addEventListener(
SDK.WebAuthnModel.Events.CREDENTIAL_ADDED, this.#addCredential.bind(this, authenticatorId));
this.#model.addEventListener(
SDK.WebAuthnModel.Events.CREDENTIAL_ASSERTED, this.#updateCredential.bind(this, authenticatorId));
this.#model.addEventListener(
SDK.WebAuthnModel.Events.CREDENTIAL_UPDATED, this.#updateCredential.bind(this, authenticatorId));
this.#model.addEventListener(
SDK.WebAuthnModel.Events.CREDENTIAL_DELETED, this.#deleteCredential.bind(this, authenticatorId));
}
section.createChild('div', 'divider');
return section;
}
#exportCredential(credential: Protocol.WebAuthn.Credential): void {
let pem = PRIVATE_KEY_HEADER;
for (let i = 0; i < credential.privateKey.length; i += 64) {
pem += credential.privateKey.substring(i, i + 64) + '\n';
}
pem += PRIVATE_KEY_FOOTER;
const link = document.createElement('a');
link.download = i18nString(UIStrings.privateKeypem);
link.href = 'data:application/x-pem-file,' + encodeURIComponent(pem);
link.click();
}
#removeCredential(authenticatorId: Protocol.WebAuthn.AuthenticatorId, credentialId: string): void {
const dataGrid = this.dataGrids.get(authenticatorId);
if (!dataGrid) {
return;
}
// @ts-expect-error dataGrid node type is indeterminate.
dataGrid.rootNode()
.children.find((n: DataGrid.DataGrid.DataGridNode<DataGridNode>) => n.data.credentialId === credentialId)
.remove();
if (!dataGrid.rootNode().children.length) {
dataGrid.rootNode().appendChild(new EmptyDataGridNode());
}
if (this.#model) {
void this.#model.removeCredential(authenticatorId, credentialId);
}
}
/**
* Creates the fields describing the authenticator in the front end.
*/
#createAuthenticatorFields(
section: Element, authenticatorId: string, options: Protocol.WebAuthn.VirtualAuthenticatorOptions): void {
const sectionFields = section.createChild('div', 'authenticator-fields');
const uuidField = sectionFields.createChild('div', 'authenticator-field');
const protocolField = sectionFields.createChild('div', 'authenticator-field');
const transportField = sectionFields.createChild('div', 'authenticator-field');
const srkField = sectionFields.createChild('div', 'authenticator-field');
const slbField = sectionFields.createChild('div', 'authenticator-field');
const suvField = sectionFields.createChild('div', 'authenticator-field');
uuidField.appendChild(UI.UIUtils.createLabel(i18nString(UIStrings.uuid), 'authenticator-option-label'));
protocolField.appendChild(UI.UIUtils.createLabel(i18nString(UIStrings.protocol), 'authenticator-option-label'));
transportField.appendChild(UI.UIUtils.createLabel(i18nString(UIStrings.transport), 'authenticator-option-label'));
srkField.appendChild(
UI.UIUtils.createLabel(i18nString(UIStrings.supportsResidentKeys), 'authenticator-option-label'));
slbField.appendChild(UI.UIUtils.createLabel(i18nString(UIStrings.supportsLargeBlob), 'authenticator-option-label'));
suvField.appendChild(
UI.UIUtils.createLabel(i18nString(UIStrings.supportsUserVerification), 'authenticator-option-label'));
uuidField.createChild('div', 'authenticator-field-value').textContent = authenticatorId;
protocolField.createChild('div', 'authenticator-field-value').textContent = options.protocol;
transportField.createChild('div', 'authenticator-field-value').textContent = options.transport;
srkField.createChild('div', 'authenticator-field-value').textContent =
options.hasResidentKey ? i18nString(UIStrings.yes) : i18nString(UIStrings.no);
slbField.createChild('div', 'authenticator-field-value').textContent =
options.hasLargeBlob ? i18nString(UIStrings.yes) : i18nString(UIStrings.no);
suvField.createChild('div', 'authenticator-field-value').textContent =
options.hasUserVerification ? i18nString(UIStrings.yes) : i18nString(UIStrings.no);
}
#handleEditNameButton(
titleElement: Element, nameField: HTMLInputElement, editName: UI.Toolbar.ToolbarButton,
saveName: UI.Toolbar.ToolbarButton): void {
nameField.disabled = false;
titleElement.classList.add('editing-name');
nameField.focus();
saveName.setVisible(true);
editName.setVisible(false);
}
#handleSaveNameButton(
titleElement: Element, nameField: HTMLInputElement, editName: UI.Toolbar.ToolbarItem,
saveName: UI.Toolbar.ToolbarItem, activeLabel: HTMLLabelElement): void {
const name = nameField.value;
if (!name) {
return;
}
nameField.disabled = true;
titleElement.classList.remove('editing-name');
editName.setVisible(true);
saveName.setVisible(false);
this.#updateActiveLabelTitle(activeLabel, name);
}
#updateActiveLabelTitle(activeLabel: HTMLLabelElement, authenticatorName: string): void {
UI.Tooltip.Tooltip.install(
activeLabel, i18nString(UIStrings.setSAsTheActiveAuthenticator, {PH1: authenticatorName}));
}
/**
* Removes both the authenticator and its respective UI element.
*/
removeAuthenticator(authenticatorId: Protocol.WebAuthn.AuthenticatorId): void {
if (this.#authenticatorsView) {
const child = this.#authenticatorsView.querySelector(`[data-authenticator-id=${CSS.escape(authenticatorId)}]`);
if (child) {
child.remove();
}
}
const dataGrid = this.dataGrids.get(authenticatorId);
if (dataGrid) {
dataGrid.asWidget().detach();
this.dataGrids.delete(authenticatorId);
}
if (this.#model) {
void this.#model.removeAuthenticator(authenticatorId);
}
// Update available authenticator setting.
const prevAvailableAuthenticators = this.#availableAuthenticatorSetting.get();
const newAvailableAuthenticators = prevAvailableAuthenticators.filter(a => a.authenticatorId !== authenticatorId);
this.#availableAuthenticatorSetting.set(newAvailableAuthenticators);
if (this.#activeAuthId === authenticatorId) {
const availableAuthenticatorIds = Array.from(this.dataGrids.keys());
if (availableAuthenticatorIds.length) {
void this.#setActiveAuthenticator(availableAuthenticatorIds[0]);
} else {
this.#activeAuthId = null;
}
}
this.#updateInternalTransportAvailability();
}
#createOptionsFromCurrentInputs(): Protocol.WebAuthn.VirtualAuthenticatorOptions {
// TODO(crbug.com/1034663): Add optionality for isUserVerified param.
if (!this.#protocolSelect || !this.transportSelect || !this.residentKeyCheckbox ||
!this.#userVerificationCheckbox || !this.largeBlobCheckbox) {
throw new Error('Unable to create options from current inputs');
}
return {
protocol: this.#protocolSelect.options[this.#protocolSelect.selectedIndex].value as
Protocol.WebAuthn.AuthenticatorProtocol,
ctap2Version: Protocol.WebAuthn.Ctap2Version.Ctap2_1,
transport: this.transportSelect.options[this.transportSelect.selectedIndex].value as
Protocol.WebAuthn.AuthenticatorTransport,
hasResidentKey: this.residentKeyCheckbox.checked,
hasUserVerification: this.#userVerificationCheckbox.checked,
hasLargeBlob: this.largeBlobCheckbox.checked,
automaticPresenceSimulation: true,
isUserVerified: true,
};
}
/**
* Sets the given authenticator as active.
* Note that a newly added authenticator will automatically be set as active.
*/
async #setActiveAuthenticator(authenticatorId: Protocol.WebAuthn.AuthenticatorId): Promise<void> {
await this.#clearActiveAuthenticator();
if (this.#model) {
await this.#model.setAutomaticPresenceSimulation(authenticatorId, true);
}
this.#activeAuthId = authenticatorId;
const prevAvailableAuthenticators = this.#availableAuthenticatorSetting.get();
const newAvailableAuthenticators =
prevAvailableAuthenticators.map(a => ({...a, active: a.authenticatorId === authenticatorId}));
this.#availableAuthenticatorSetting.set(newAvailableAuthenticators);
this.#updateActiveButtons();
}
#updateActiveButtons(): void {
const authenticators = this.#authenticatorsView.getElementsByClassName('authenticator-section');
Array.from(authenticators).forEach((authenticator: Element) => {
const button = (authenticator.querySelector('input[type="radio"]') as HTMLInputElement);
if (!button) {
return;
}
button.checked = (authenticator as HTMLElement).dataset.authenticatorId === this.#activeAuthId;
});
}
async #clearActiveAuthenticator(): Promise<void> {
if (this.#activeAuthId && this.#model) {
await this.#model.setAutomaticPresenceSimulation(this.#activeAuthId, false);
}
this.#activeAuthId = null;
this.#updateActiveButtons();
}
}