// 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` ${credentials.length ? repeat(credentials, c => c.credentialId, credential => html` `) : html` `}
${i18nString(UIStrings.id)} ${i18nString(UIStrings.isResident)} ${i18nString(UIStrings.rpId)} ${i18nString(UIStrings.userHandle)} ${i18nString(UIStrings.signCount)} ${i18nString(UIStrings.actions)}
${credential.credentialId} ${credential.isResidentCredential} ${credential.rpId} ${credential.userHandle} ${credential.signCount} onExport(credential)} .jslogContext=${'webauthn.export-credential'}> ${i18nString(UIStrings.export)} onRemove(credential.credentialId)} .jslogContext=${'webauthn.remove-credential'}> ${i18nString(UIStrings.remove)}
${i18nTemplate(UIStrings.noCredentialsTryCallingSFromYour, {PH1: html`navigator.credentials.create()`})}
`; // 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 = { 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; activeAuthenticatorId: Protocol.WebAuthn.AuthenticatorId|null; editingAuthenticatorId: Protocol.WebAuthn.AuthenticatorId|null; newAuthenticatorOptions: Protocol.WebAuthn.VirtualAuthenticatorOptions; internalTransportAvailable: boolean; updateNewAuthenticatorOptions: (change: Partial) => 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 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` `; // clang-format on } function renderLearnMoreView(): Lit.TemplateResult { // clang-format off return html` `; // clang-format on } function renderNewAuthenticatorSection( options: Protocol.WebAuthn.VirtualAuthenticatorOptions, internalTransportAvailable: boolean, onUpdate: (change: Partial) => void, onAdd: () => void): Lit.TemplateResult { const isCtap2 = options.protocol === Protocol.WebAuthn.AuthenticatorProtocol.Ctap2; // clang-format off return html`
onUpdate({hasResidentKey: (e.target as HTMLInputElement).checked})} .checked=${Boolean(options.hasResidentKey && isCtap2)} .disabled=${!isCtap2}>
onUpdate({hasUserVerification: (e.target as HTMLInputElement).checked})} .checked=${Boolean(options.hasUserVerification && isCtap2)} .disabled=${!isCtap2}>
onUpdate({hasLargeBlob: (e.target as HTMLInputElement).checked})} .checked=${Boolean(options.hasLargeBlob && isCtap2 && options.hasResidentKey)} .disabled=${!options.hasResidentKey || !isCtap2}>
${i18nString(UIStrings.add)}
`; } 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`
{ output.revealSection.set(authenticatorId, revealSection.bind(null, e));})}>
onSaveName(((e.target as HTMLElement).parentElement?.nextSibling as HTMLInputElement).value)} .iconName=${'checkmark'} .variant=${Buttons.Button.Variant.TOOLBAR} class=${classMap({hidden: !editing})} .jslogContext=${'save-name'}> { 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); } }}>
${renderAuthenticatorFields(authenticatorId, authenticator.options)}
${i18nString(UIStrings.credentials)}
${renderCredentialsDataGrid(authenticatorId, authenticator.credentials, onExportCredential, onRemoveCredential)}
`; // 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`
${authenticatorId}
${options.protocol}
${options.transport}
${options.hasResidentKey ? i18nString(UIStrings.yes) : i18nString(UIStrings.no)}
${options.hasLargeBlob ? i18nString(UIStrings.yes) : i18nString(UIStrings.no)}
${options.hasUserVerification ? i18nString(UIStrings.yes) : i18nString(UIStrings.no)}
`; // clang-format on } export const DEFAULT_VIEW: ViewFunction = (input, output, target) => { // clang-format off render(html`
${renderToolbar(input.enabled, input.onToggleEnabled)}
${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))}
${renderLearnMoreView()} ${renderNewAuthenticatorSection( input.newAuthenticatorOptions, input.internalTransportAvailable, input.updateNewAuthenticatorOptions, input.addAuthenticator)}
`, target); // clang-format on }; export class WebauthnPaneImpl extends UI.Panel.Panel implements SDK.TargetManager.SDKModelObserver { async #addAuthenticator(options: Protocol.WebAuthn.VirtualAuthenticatorOptions): Promise { 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(); #enabled = false; readonly #availableAuthenticatorSetting: Common.Settings.Setting; #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; #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( '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 { 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 { this.#enabled = false; await this.#setVirtualAuthEnvEnabled(false); } #addCredential(authenticatorId: Protocol.WebAuthn.AuthenticatorId, { data: event, }: Common.EventTarget.EventTargetEvent): 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): 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): 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 { await this.#isEnabling; this.#isEnabling = new Promise(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): 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 { 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 { 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 { if (this.#activeAuthId && this.#model) { await this.#model.setAutomaticPresenceSimulation(this.#activeAuthId, false); } this.#activeAuthId = null; this.requestUpdate(); } }