UNPKG

chrome-devtools-frontend

Version:
866 lines (804 loc) • 36.4 kB
// Copyright 2020 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import '../../ui/legacy/legacy.js'; import '../../ui/legacy/components/data_grid/data_grid.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 Input from '../../ui/components/input/input.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, Directives: {ref, repeat, classMap}} = Lit; const {widgetConfig} = UI.Widget; 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 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; function renderCredentialsDataGrid( authenticatorId: Protocol.WebAuthn.AuthenticatorId, credentials: Protocol.WebAuthn.Credential[], onExport: (credential: Protocol.WebAuthn.Credential) => void, onRemove: (credentialId: string) => void): Lit.TemplateResult { // clang-format off return html` <devtools-data-grid name=${i18nString(UIStrings.credentials)} inline striped> <table> <thead> <tr> <th id="credentialId" weight="24" text-overflow="ellipsis">${i18nString(UIStrings.id)}</th> <th id="isResidentCredential" type="boolean" weight="10">${i18nString(UIStrings.isResident)}</th> <th id="rpId" weight="16.5">${i18nString(UIStrings.rpId)}</th> <th id="userHandle" weight="16.5">${i18nString(UIStrings.userHandle)}</th> <th id="signCount" weight="16.5">${i18nString(UIStrings.signCount)}</th> <th id="actions" weight="16.5">${i18nString(UIStrings.actions)}</th> </tr> </thead> <tbody> ${credentials.length ? repeat(credentials, c => c.credentialId, credential => html` <tr> <td>${credential.credentialId}</td> <td>${credential.isResidentCredential}</td> <td>${credential.rpId}</td> <td>${credential.userHandle}</td> <td>${credential.signCount}</td> <td> <devtools-button .variant=${Buttons.Button.Variant.OUTLINED} part="action-button" @click=${() => onExport(credential)} .jslogContext=${'webauthn.export-credential'}> ${i18nString(UIStrings.export)} </devtools-button> <devtools-button .variant=${Buttons.Button.Variant.OUTLINED} part="action-button" @click=${() => onRemove(credential.credentialId)} .jslogContext=${'webauthn.remove-credential'}> ${i18nString(UIStrings.remove)} </devtools-button> </td> </tr>`) : html` <tr> <td class="center" colspan=6> ${i18nTemplate(UIStrings.noCredentialsTryCallingSFromYour, {PH1: html`<span class="code">navigator.credentials.create()</span>`})} </td> </tr>`} </tbody> </table> </devtools-data-grid>`; // 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, }; interface Authenticator { name: string; options: Protocol.WebAuthn.VirtualAuthenticatorOptions; credentials: Protocol.WebAuthn.Credential[]; } interface Authenticator { name: string; options: Protocol.WebAuthn.VirtualAuthenticatorOptions; credentials: Protocol.WebAuthn.Credential[]; } interface ViewInput { enabled: boolean; onToggleEnabled: () => void; authenticators: Map<Protocol.WebAuthn.AuthenticatorId, Authenticator>; activeAuthenticatorId: Protocol.WebAuthn.AuthenticatorId|null; editingAuthenticatorId: Protocol.WebAuthn.AuthenticatorId|null; newAuthenticatorOptions: Protocol.WebAuthn.VirtualAuthenticatorOptions; internalTransportAvailable: boolean; updateNewAuthenticatorOptions: (change: Partial<Protocol.WebAuthn.VirtualAuthenticatorOptions>) => void; addAuthenticator: () => void; onActivateAuthenticator: (id: Protocol.WebAuthn.AuthenticatorId) => void; onEditName: (id: Protocol.WebAuthn.AuthenticatorId) => void; onSaveName: (id: Protocol.WebAuthn.AuthenticatorId, name: string) => void; onRemoveAuthenticator: (id: Protocol.WebAuthn.AuthenticatorId) => void; onExportCredential: (credential: Protocol.WebAuthn.Credential) => void; onRemoveCredential: (id: Protocol.WebAuthn.AuthenticatorId, credentialId: string) => void; } interface ViewOutput { revealSection: Map<string, () => void>; } type ViewFunction = (input: ViewInput, output: ViewOutput, target: HTMLElement) => void; function renderToolbar(enabled: boolean, onToggle: () => void): Lit.TemplateResult { const enableCheckboxTitle = i18nString(UIStrings.enableVirtualAuthenticator); // clang-format off return html` <div class="webauthn-toolbar-container" jslog=${VisualLogging.toolbar()} role="toolbar"> <devtools-toolbar class="webauthn-toolbar" role="presentation"> <devtools-checkbox title=${enableCheckboxTitle} @click=${onToggle} .jslogContext=${'virtual-authenticators'} .checked=${enabled}> ${enableCheckboxTitle} </devtools-checkbox> </devtools-toolbar> </div>`; // clang-format on } function renderLearnMoreView(): Lit.TemplateResult { // clang-format off return html` <devtools-widget class="learn-more" .widgetConfig=${widgetConfig(UI.EmptyWidget.EmptyWidget, { header: i18nString(UIStrings.noAuthenticator), text: i18nString(UIStrings.useWebauthnForPhishingresistant), link: WEB_AUTHN_EXPLANATION_URL})}> </devtools-widget>`; // clang-format on } function renderNewAuthenticatorSection( options: Protocol.WebAuthn.VirtualAuthenticatorOptions, internalTransportAvailable: boolean, onUpdate: (change: Partial<Protocol.WebAuthn.VirtualAuthenticatorOptions>) => void, onAdd: () => void): Lit.TemplateResult { const isCtap2 = options.protocol === Protocol.WebAuthn.AuthenticatorProtocol.Ctap2; // clang-format off return html` <div class="new-authenticator-container"> <label class="new-authenticator-title"> ${i18nString(UIStrings.newAuthenticator)} </label> <div class="new-authenticator-form" jslog=${VisualLogging.section('new-authenticator')}> <div class="authenticator-option"> <label class="authenticator-option-label" for="protocol"> ${i18nString(UIStrings.protocol)} </label> <select id="protocol" jslog=${VisualLogging.dropDown('protocol').track({change: true})} value=${options.protocol} @change=${(e:Event) => onUpdate({protocol: (e.target as HTMLSelectElement).value as Protocol.WebAuthn.AuthenticatorProtocol})}> ${Object.values(PROTOCOL_AUTHENTICATOR_VALUES).sort().map(option => html` <option value=${option} jslog=${VisualLogging.item(option).track({click: true})}> ${option} </option>`)} </select> </div> <div class="authenticator-option"> <label for="transport" class="authenticator-option-label"> ${i18nString(UIStrings.transport)} </label> <select id="transport" value=${options.transport} jslog=${VisualLogging.dropDown('transport').track({change: true})} @change=${(e: Event) => onUpdate({transport: (e.target as HTMLSelectElement).value as Protocol.WebAuthn.AuthenticatorTransport})}> ${[ Protocol.WebAuthn.AuthenticatorTransport.Usb, Protocol.WebAuthn.AuthenticatorTransport.Ble, Protocol.WebAuthn.AuthenticatorTransport.Nfc, ...(isCtap2 ? [Protocol.WebAuthn.AuthenticatorTransport.Internal] : []) ].map(option => html` <option value=${option} jslog=${VisualLogging.item(option).track({click: true})} .selected=${options.transport === option} .disabled=${!internalTransportAvailable && option === Protocol.WebAuthn.AuthenticatorTransport.Internal}> ${option} </option>`) } </select> </div> <div class="authenticator-option"> <label for="resident-key" class="authenticator-option-label"> ${i18nString(UIStrings.supportsResidentKeys)} </label> <input id="resident-key" class="authenticator-option-checkbox" type="checkbox" jslog=${VisualLogging.toggle('resident-key').track({change: true})} @change=${(e:Event) => onUpdate({hasResidentKey: (e.target as HTMLInputElement).checked})} .checked=${Boolean(options.hasResidentKey && isCtap2)} .disabled=${!isCtap2}> </div> <div class="authenticator-option"> <label for="user-verification" class="authenticator-option-label"> ${i18nString(UIStrings.supportsUserVerification)} </label> <input id="user-verification" class="authenticator-option-checkbox" type="checkbox" jslog=${VisualLogging.toggle('user-verification').track({change: true})} @change=${(e: Event) => onUpdate({hasUserVerification: (e.target as HTMLInputElement).checked})} .checked=${Boolean(options.hasUserVerification && isCtap2)} .disabled=${!isCtap2}> </div> <div class="authenticator-option"> <label for="large-blob" class="authenticator-option-label"> ${i18nString(UIStrings.supportsLargeBlob)} </label> <input id="large-blob" class="authenticator-option-checkbox" type="checkbox" jslog=${VisualLogging.toggle('large-blob').track({change: true})} @change=${(e: Event) => onUpdate({hasLargeBlob: (e.target as HTMLInputElement).checked})} .checked=${Boolean(options.hasLargeBlob && isCtap2 && options.hasResidentKey)} .disabled=${!options.hasResidentKey || !isCtap2}> </div> <div class="authenticator-option"> <div class="authenticator-option-label"></div> <devtools-button @click=${onAdd} id="add-authenticator" .jslogContext=${'webauthn.add-authenticator'} .variant=${Buttons.Button.Variant.OUTLINED}> ${i18nString(UIStrings.add)} </devtools-button> </div> </div> </div>`; } function renderAuthenticatorSection( authenticatorId: Protocol.WebAuthn.AuthenticatorId, authenticator: Authenticator, active: boolean, editing: boolean, onActivate: () => void, onEditName: () => void, onSaveName: (name: string) => void, onRemove: () => void, onExportCredential : (credential: Protocol.WebAuthn.Credential) => void, onRemoveCredential : (credentialId: string) => void, output: ViewOutput): Lit.TemplateResult { function revealSection(section: Element|undefined): void { if (!section) { return; } const mediaQueryList = window.matchMedia('(prefers-reduced-motion: reduce)'); const prefersReducedMotion = mediaQueryList.matches; section.scrollIntoView({block: 'nearest', behavior: prefersReducedMotion ? 'auto' : 'smooth'}); } // clang-format off return html` <div class="authenticator-section" data-authenticator-id=${authenticatorId} jslog=${VisualLogging.section('authenticator')} ${ref(e => { output.revealSection.set(authenticatorId, revealSection.bind(null, e));})}> <div class="authenticator-section-header"> <div class="authenticator-section-title" role="heading" aria-level="2"> <devtools-toolbar class="edit-name-toolbar"> <devtools-button title=${i18nString(UIStrings.editName)} class=${classMap({hidden: editing})} @click=${onEditName} .iconName=${'edit'} .variant=${Buttons.Button.Variant.TOOLBAR} .jslogContext=${'edit-name'}></devtools-button> <devtools-button title=${i18nString(UIStrings.saveName)} @click=${(e: Event) => onSaveName(((e.target as HTMLElement).parentElement?.nextSibling as HTMLInputElement).value)} .iconName=${'checkmark'} .variant=${Buttons.Button.Variant.TOOLBAR} class=${classMap({hidden: !editing})} .jslogContext=${'save-name'}></devtools-button> </devtools-toolbar> <input class="authenticator-name-field" placeholder=${i18nString(UIStrings.enterNewName)} jslog=${VisualLogging.textField('name').track({keydown: 'Enter', change: true})} value=${i18nString(UIStrings.authenticatorS, {PH1: authenticator.name})} .disabled=${!editing} ${ref(e => { if(e instanceof HTMLInputElement && editing) { e.focus(); } })} @focusout=${(e: Event) => onSaveName((e.target as HTMLInputElement).value)} @keydown=${(event: KeyboardEvent) => { if (event.key === 'Enter') { onSaveName((event.target as HTMLInputElement).value); } }}> </div> <div class="active-button-container"> <label title=${i18nString(UIStrings.setSAsTheActiveAuthenticator, {PH1: authenticator.name})}> <input type="radio" .checked=${active} @change=${(e:Event) => { if ((e.target as HTMLInputElement).checked) { onActivate(); }}} jslog=${VisualLogging.toggle('webauthn.active-authenticator').track({change: true})}> ${i18nString(UIStrings.active)} </label> </div> <button class="text-button" @click=${onRemove} jslog=${VisualLogging.action('webauthn.remove-authenticator').track({click: true})}> ${i18nString(UIStrings.remove)} </button> </div> ${renderAuthenticatorFields(authenticatorId, authenticator.options)} <div class="credentials-title">${i18nString(UIStrings.credentials)}</div> ${renderCredentialsDataGrid(authenticatorId, authenticator.credentials, onExportCredential, onRemoveCredential)} <div class="divider"></div> </div>`; // clang-format on } /** * Creates the fields describing the authenticator in the front end. */ function renderAuthenticatorFields( authenticatorId: string, options: Protocol.WebAuthn.VirtualAuthenticatorOptions): Lit.TemplateResult { // clang-format off return html` <div class="authenticator-fields"> <div class="authenticator-field"> <label class="authenticator-option-label">${i18nString(UIStrings.uuid)}</label> <div class="authenticator-field-value">${authenticatorId}</div> </div> <div class="authenticator-field"> <label class="authenticator-option-label">${i18nString(UIStrings.protocol)}</label> <div class="authenticator-field-value">${options.protocol}</div> </div> <div class="authenticator-field"> <label class="authenticator-option-label">${i18nString(UIStrings.transport)}</label> <div class="authenticator-field-value">${options.transport}</div> </div> <div class="authenticator-field"> <label class="authenticator-option-label"> ${i18nString(UIStrings.supportsResidentKeys)} </label> <div class="authenticator-field-value"> ${options.hasResidentKey ? i18nString(UIStrings.yes) : i18nString(UIStrings.no)} </div> </div> <div class="authenticator-field"> <label class="authenticator-option-label"> ${i18nString(UIStrings.supportsLargeBlob)} </label> <div class="authenticator-field-value"> ${options.hasLargeBlob ? i18nString(UIStrings.yes) : i18nString(UIStrings.no)} </div> </div> <div class="authenticator-field"> <label class="authenticator-option-label"> ${i18nString(UIStrings.supportsUserVerification)} </label> <div class="authenticator-field-value"> ${options.hasUserVerification ? i18nString(UIStrings.yes) : i18nString(UIStrings.no)} </div> </div> </div>`; // clang-format on } export const DEFAULT_VIEW: ViewFunction = (input, output, target) => { // clang-format off render(html` <style>${Input.checkboxStyles}</style> <style>${webauthnPaneStyles}</style> <div class="webauthn-pane flex-auto ${classMap({enabled: input.enabled})}"> ${renderToolbar(input.enabled, input.onToggleEnabled)} <div class="authenticators-view"> ${repeat([...input.authenticators.entries()], ([id]) => id, ([id, authenticator]) => renderAuthenticatorSection( id, authenticator, input.activeAuthenticatorId === id, input.editingAuthenticatorId === id, input.onActivateAuthenticator.bind(input, id), input.onEditName.bind(input, id), input.onSaveName.bind(input, id), input.onRemoveAuthenticator.bind(input, id), input.onExportCredential, input.onRemoveCredential.bind(input, id), output))} </div> ${renderLearnMoreView()} ${renderNewAuthenticatorSection( input.newAuthenticatorOptions, input.internalTransportAvailable, input.updateNewAuthenticatorOptions, input.addAuthenticator)} </div>`, target); // clang-format on }; export class WebauthnPaneImpl extends UI.Panel.Panel implements SDK.TargetManager.SDKModelObserver<SDK.WebAuthnModel.WebAuthnModel> { async #addAuthenticator(options: Protocol.WebAuthn.VirtualAuthenticatorOptions): Promise<Protocol.WebAuthn.AuthenticatorId> { if (!this.#model) { throw new Error('WebAuthn model is not available.'); } const authenticatorId = await this.#model.addAuthenticator(options); const userFriendlyName = authenticatorId.slice(-5); // User friendly name defaults to last 5 chars of UUID. this.#authenticators.set(authenticatorId, { name: userFriendlyName, options, credentials: [], }); this.requestUpdate(); 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)); return authenticatorId; } #activeAuthId: Protocol.WebAuthn.AuthenticatorId|null = null; #editingAuthId: Protocol.WebAuthn.AuthenticatorId|null = null; #hasBeenEnabled = false; readonly #authenticators = new Map<Protocol.WebAuthn.AuthenticatorId, Authenticator>(); #enabled = false; readonly #availableAuthenticatorSetting: Common.Settings.Setting<AvailableAuthenticatorOptions[]>; #model?: SDK.WebAuthnModel.WebAuthnModel; #newAuthenticatorOptions: Protocol.WebAuthn.VirtualAuthenticatorOptions = { protocol: Protocol.WebAuthn.AuthenticatorProtocol.Ctap2, ctap2Version: Protocol.WebAuthn.Ctap2Version.Ctap2_1, transport: Protocol.WebAuthn.AuthenticatorTransport.Usb, hasResidentKey: false, hasUserVerification: false, hasLargeBlob: false, automaticPresenceSimulation: true, isUserVerified: true, }; #hasInternalAuthenticator = false; #isEnabling?: Promise<void>; #view: ViewFunction; #viewOutput: ViewOutput = { revealSection: new Map(), }; constructor(view = DEFAULT_VIEW) { super('webauthn'); this.#view = view; SDK.TargetManager.TargetManager.instance().observeModels(SDK.WebAuthnModel.WebAuthnModel, this, {scoped: true}); this.#availableAuthenticatorSetting = Common.Settings.Settings.instance().createSetting<AvailableAuthenticatorOptions[]>( 'webauthn-authenticators', []); this.#updateInternalTransportAvailability(); this.performUpdate(); } override performUpdate(): void { const viewInput = { enabled: this.#enabled, onToggleEnabled: this.#handleCheckboxToggle.bind(this), authenticators: this.#authenticators, activeAuthenticatorId: this.#activeAuthId, editingAuthenticatorId: this.#editingAuthId, newAuthenticatorOptions: this.#newAuthenticatorOptions, internalTransportAvailable: !this.#hasInternalAuthenticator, updateNewAuthenticatorOptions: this.#updateNewAuthenticatorSectionOptions.bind(this), addAuthenticator: this.#handleAddAuthenticatorButton.bind(this), onActivateAuthenticator: this.#setActiveAuthenticator.bind(this), onEditName: this.#handleEditNameButton.bind(this), onSaveName: this.#handleSaveNameButton.bind(this), onRemoveAuthenticator: this.removeAuthenticator.bind(this), onExportCredential: this.#exportCredential.bind(this), onRemoveCredential: this.#removeCredential.bind(this), }; this.#view(viewInput, this.#viewOutput, this.contentElement); } 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.#addAuthenticator(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> { this.#enabled = false; await this.#setVirtualAuthEnvEnabled(false); } #addCredential(authenticatorId: Protocol.WebAuthn.AuthenticatorId, { data: event, }: Common.EventTarget.EventTargetEvent<Protocol.WebAuthn.CredentialAddedEvent>): void { const authenticator = this.#authenticators.get(authenticatorId); if (!authenticator) { return; } authenticator.credentials.push(event.credential); this.requestUpdate(); } #updateCredential( authenticatorId: Protocol.WebAuthn.AuthenticatorId, { data: event, }: Common.EventTarget .EventTargetEvent<Protocol.WebAuthn.CredentialAssertedEvent&Protocol.WebAuthn.CredentialUpdatedEvent>): void { const authenticator = this.#authenticators.get(authenticatorId); if (!authenticator) { return; } const credential = authenticator.credentials.find(credential => credential.credentialId === event.credential.credentialId); if (!credential) { return; } Object.assign(credential, event.credential); this.requestUpdate(); } #deleteCredential(authenticatorId: Protocol.WebAuthn.AuthenticatorId, { data: event, }: Common.EventTarget.EventTargetEvent<Protocol.WebAuthn.CredentialDeletedEvent>): void { const authenticator = this.#authenticators.get(authenticatorId); if (!authenticator) { return; } const credentialIndex = authenticator.credentials.findIndex(credential => credential.credentialId === event.credentialId); if (credentialIndex < 0) { return; } authenticator.credentials.splice(credentialIndex, 1); this.requestUpdate(); } 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.#isEnabling = undefined; this.#enabled = enable; this.requestUpdate(); resolve(); }); } #removeAuthenticatorSections(): void { this.#authenticators.clear(); } #handleCheckboxToggle(): void { void this.#setVirtualAuthEnvEnabled(!this.#enabled); } #updateNewAuthenticatorSectionOptions(change: Partial<Protocol.WebAuthn.VirtualAuthenticatorOptions>): void { Object.assign(this.#newAuthenticatorOptions, change); this.requestUpdate(); } #updateInternalTransportAvailability(): void { this.#hasInternalAuthenticator = Boolean(this.#availableAuthenticatorSetting.get().find( authenticator => authenticator.transport === Protocol.WebAuthn.AuthenticatorTransport.Internal)); if (this.#hasInternalAuthenticator && this.#newAuthenticatorOptions.transport === Protocol.WebAuthn.AuthenticatorTransport.Internal) { this.#newAuthenticatorOptions.transport = Protocol.WebAuthn.AuthenticatorTransport.Nfc; } this.requestUpdate(); } async #handleAddAuthenticatorButton(): Promise<void> { const options = {...this.#newAuthenticatorOptions}; if (this.#model) { const authenticatorId = await this.#addAuthenticator(options); this.#activeAuthId = authenticatorId; // Newly added authenticator is automatically set as active. const availableAuthenticators = this.#availableAuthenticatorSetting.get(); availableAuthenticators.push({authenticatorId, active: true, ...options}); this.#availableAuthenticatorSetting.set( availableAuthenticators.map(a => ({...a, active: a.authenticatorId === authenticatorId}))); this.#updateInternalTransportAvailability(); await this.updateComplete; this.#viewOutput.revealSection.get(authenticatorId)?.(); } } #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; /* eslint-disable-next-line @devtools/no-imperative-dom-api */ 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 authenticator = this.#authenticators.get(authenticatorId); if (!authenticator) { return; } const authenticatorIndex = authenticator.credentials.findIndex(credential => credential.credentialId === credentialId); if (authenticatorIndex < 0) { return; } authenticator.credentials.splice(authenticatorIndex, 1); this.requestUpdate(); if (this.#model) { void this.#model.removeCredential(authenticatorId, credentialId); } } #handleEditNameButton(authenticatorId: Protocol.WebAuthn.AuthenticatorId): void { this.#editingAuthId = authenticatorId; this.requestUpdate(); } #handleSaveNameButton(authenticatorId: Protocol.WebAuthn.AuthenticatorId, name: string): void { const authenticator = this.#authenticators.get(authenticatorId); if (!authenticator) { return; } authenticator.name = name; this.#editingAuthId = null; this.requestUpdate(); } /** * Removes both the authenticator and its respective UI element. */ removeAuthenticator(authenticatorId: Protocol.WebAuthn.AuthenticatorId): void { this.#authenticators.delete(authenticatorId); this.requestUpdate(); 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.#authenticators.keys()); if (availableAuthenticatorIds.length) { void this.#setActiveAuthenticator(availableAuthenticatorIds[0]); } else { this.#activeAuthId = null; } } this.#updateInternalTransportAvailability(); } /** * 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.requestUpdate(); } async #clearActiveAuthenticator(): Promise<void> { if (this.#activeAuthId && this.#model) { await this.#model.setAutomaticPresenceSimulation(this.#activeAuthId, false); } this.#activeAuthId = null; this.requestUpdate(); } }