[WebAuthn] Add large blob toggle

Add a toggle that enables / disables large blob support for WebAuthn
virtual authenticators. This feature has been long supported by the
devtools protocol but we hadn't surfaced it on the front-end.

Large blobs are only available for resident-key enabled authenticators,
so we disable the checkbox if that option is not selected. Also, large
blobs require CTAP 2.1.

Fixed: 1321803
Change-Id: Ic1654276ba5a39ddd8943cd8689978117546e70e
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/3625237
Reviewed-by: Simon Zünd <szuend@chromium.org>
Commit-Queue: Nina Satragno <nsatragno@chromium.org>
Auto-Submit: Nina Satragno <nsatragno@chromium.org>
diff --git a/front_end/core/i18n/locales/en-US.json b/front_end/core/i18n/locales/en-US.json
index 835aa3b..f0f02c9 100644
--- a/front_end/core/i18n/locales/en-US.json
+++ b/front_end/core/i18n/locales/en-US.json
@@ -11531,6 +11531,9 @@
   "panels/webauthn/WebauthnPane.ts | signCount": {
     "message": "Signature Count"
   },
+  "panels/webauthn/WebauthnPane.ts | supportsLargeBlob": {
+    "message": "Supports large blob"
+  },
   "panels/webauthn/WebauthnPane.ts | supportsResidentKeys": {
     "message": "Supports resident keys"
   },
diff --git a/front_end/core/i18n/locales/en-XL.json b/front_end/core/i18n/locales/en-XL.json
index d49568a..64b0777 100644
--- a/front_end/core/i18n/locales/en-XL.json
+++ b/front_end/core/i18n/locales/en-XL.json
@@ -11531,6 +11531,9 @@
   "panels/webauthn/WebauthnPane.ts | signCount": {
     "message": "Ŝíĝńât́ûŕê Ćôún̂t́"
   },
+  "panels/webauthn/WebauthnPane.ts | supportsLargeBlob": {
+    "message": "Ŝúp̂ṕôŕt̂ś l̂ár̂ǵê b́l̂ób̂"
+  },
   "panels/webauthn/WebauthnPane.ts | supportsResidentKeys": {
     "message": "Ŝúp̂ṕôŕt̂ś r̂éŝíd̂én̂t́ k̂éŷś"
   },
diff --git a/front_end/panels/webauthn/BUILD.gn b/front_end/panels/webauthn/BUILD.gn
index c1150e4..34adb89 100644
--- a/front_end/panels/webauthn/BUILD.gn
+++ b/front_end/panels/webauthn/BUILD.gn
@@ -36,6 +36,7 @@
   visibility = [
     ":*",
     "../../../test/unittests/front_end/entrypoints/missing_entrypoints/*",
+    "../../../test/unittests/front_end/panels/webauthn/*",
     "../../entrypoints/*",
   ]
 
diff --git a/front_end/panels/webauthn/WebauthnPane.ts b/front_end/panels/webauthn/WebauthnPane.ts
index 7756269..4689bf1 100644
--- a/front_end/panels/webauthn/WebauthnPane.ts
+++ b/front_end/panels/webauthn/WebauthnPane.ts
@@ -84,6 +84,12 @@
   */
   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',
@@ -244,10 +250,12 @@
   #protocolSelect: HTMLSelectElement|undefined;
   #transportSelect: HTMLSelectElement|undefined;
   #residentKeyCheckboxLabel: UI.UIUtils.CheckboxLabel|undefined;
-  #residentKeyCheckbox: HTMLInputElement|undefined;
+  residentKeyCheckbox: HTMLInputElement|undefined;
   #userVerificationCheckboxLabel: UI.UIUtils.CheckboxLabel|undefined;
   #userVerificationCheckbox: HTMLInputElement|undefined;
-  #addAuthenticatorButton: HTMLButtonElement|undefined;
+  #largeBlobCheckboxLabel: UI.UIUtils.CheckboxLabel|undefined;
+  largeBlobCheckbox: HTMLInputElement|undefined;
+  addAuthenticatorButton: HTMLButtonElement|undefined;
   #isEnabling?: Promise<void>;
 
   constructor() {
@@ -475,13 +483,18 @@
   }
 
   #updateNewAuthenticatorSectionOptions(): void {
-    if (!this.#protocolSelect || !this.#residentKeyCheckbox || !this.#userVerificationCheckbox) {
+    if (!this.#protocolSelect || !this.residentKeyCheckbox || !this.#userVerificationCheckbox ||
+        !this.largeBlobCheckbox) {
       return;
     }
 
     if (this.#protocolSelect.value === Protocol.WebAuthn.AuthenticatorProtocol.Ctap2) {
-      this.#residentKeyCheckbox.disabled = false;
+      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,
@@ -491,10 +504,12 @@
         Protocol.WebAuthn.AuthenticatorTransport.Internal,
       ]);
     } else {
-      this.#residentKeyCheckbox.checked = false;
-      this.#residentKeyCheckbox.disabled = true;
+      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,
@@ -524,6 +539,7 @@
     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');
@@ -551,9 +567,9 @@
     this.#residentKeyCheckboxLabel = UI.UIUtils.CheckboxLabel.create(i18nString(UIStrings.supportsResidentKeys), false);
     this.#residentKeyCheckboxLabel.textElement.classList.add('authenticator-option-label');
     residentKeyGroup.appendChild(this.#residentKeyCheckboxLabel.textElement);
-    this.#residentKeyCheckbox = this.#residentKeyCheckboxLabel.checkboxElement;
-    this.#residentKeyCheckbox.checked = false;
-    this.#residentKeyCheckbox.classList.add('authenticator-option-checkbox');
+    this.residentKeyCheckbox = this.#residentKeyCheckboxLabel.checkboxElement;
+    this.residentKeyCheckbox.checked = false;
+    this.residentKeyCheckbox.classList.add('authenticator-option-checkbox');
     residentKeyGroup.appendChild(this.#residentKeyCheckboxLabel);
 
     this.#userVerificationCheckboxLabel = UI.UIUtils.CheckboxLabel.create('Supports user verification', false);
@@ -564,17 +580,29 @@
     this.#userVerificationCheckbox.classList.add('authenticator-option-checkbox');
     userVerificationGroup.appendChild(this.#userVerificationCheckboxLabel);
 
-    this.#addAuthenticatorButton =
+    this.#largeBlobCheckboxLabel = UI.UIUtils.CheckboxLabel.create(i18nString(UIStrings.supportsLargeBlob), false);
+    this.#largeBlobCheckboxLabel.textElement.classList.add('authenticator-option-label');
+    largeBlobGroup.appendChild(this.#largeBlobCheckboxLabel.textElement);
+    this.largeBlobCheckbox = this.#largeBlobCheckboxLabel.checkboxElement;
+    this.largeBlobCheckbox.checked = false;
+    this.largeBlobCheckbox.classList.add('authenticator-option-checkbox');
+    this.largeBlobCheckbox.name = 'large-blob-checkbox';
+    largeBlobGroup.appendChild(this.#largeBlobCheckboxLabel);
+
+    this.addAuthenticatorButton =
         UI.UIUtils.createTextButton(i18nString(UIStrings.add), this.#handleAddAuthenticatorButton.bind(this), '');
     addButtonGroup.createChild('div', 'authenticator-option-label');
-    addButtonGroup.appendChild(this.#addAuthenticatorButton);
+    addButtonGroup.appendChild(this.addAuthenticatorButton);
     const addAuthenticatorTitle = UI.UIUtils.createLabel(i18nString(UIStrings.addAuthenticator), '');
-    UI.ARIAUtils.bindLabelToControl(addAuthenticatorTitle, this.#addAuthenticatorButton);
+    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> {
@@ -702,6 +730,7 @@
     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'));
@@ -709,6 +738,7 @@
     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'));
 
@@ -717,6 +747,8 @@
     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);
   }
@@ -779,18 +811,20 @@
 
   #createOptionsFromCurrentInputs(): Protocol.WebAuthn.VirtualAuthenticatorOptions {
     // TODO(crbug.com/1034663): Add optionality for isUserVerified param.
-    if (!this.#protocolSelect || !this.#transportSelect || !this.#residentKeyCheckbox ||
-        !this.#userVerificationCheckbox) {
+    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,
+      hasResidentKey: this.residentKeyCheckbox.checked,
       hasUserVerification: this.#userVerificationCheckbox.checked,
+      hasLargeBlob: this.largeBlobCheckbox.checked,
       automaticPresenceSimulation: true,
       isUserVerified: true,
     };
diff --git a/test/unittests/front_end/BUILD.gn b/test/unittests/front_end/BUILD.gn
index 0c6aed0..a99235c 100644
--- a/test/unittests/front_end/BUILD.gn
+++ b/test/unittests/front_end/BUILD.gn
@@ -45,6 +45,7 @@
     "panels/timeline",
     "panels/timeline/components",
     "panels/utils",
+    "panels/webauthn",
     "test_setup",
     "third_party/i18n",
     "ui",
diff --git a/test/unittests/front_end/panels/webauthn/BUILD.gn b/test/unittests/front_end/panels/webauthn/BUILD.gn
new file mode 100644
index 0000000..1eec7c3
--- /dev/null
+++ b/test/unittests/front_end/panels/webauthn/BUILD.gn
@@ -0,0 +1,17 @@
+# Copyright 2022 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.
+
+import("../../../../../third_party/typescript/typescript.gni")
+
+ts_library("webauthn") {
+  testonly = true
+  sources = [ "WebauthnPane_test.ts" ]
+
+  deps = [
+    "../../../../../front_end/core/platform:bundle",
+    "../../../../../front_end/core/sdk:bundle",
+    "../../../../../front_end/panels/webauthn:bundle",
+    "../../helpers",
+  ]
+}
diff --git a/test/unittests/front_end/panels/webauthn/WebauthnPane_test.ts b/test/unittests/front_end/panels/webauthn/WebauthnPane_test.ts
new file mode 100644
index 0000000..126fc6d
--- /dev/null
+++ b/test/unittests/front_end/panels/webauthn/WebauthnPane_test.ts
@@ -0,0 +1,110 @@
+// Copyright (c) 2022 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.
+
+import {createTarget} from '../../helpers/EnvironmentHelpers.js';
+import {
+  describeWithMockConnection,
+  setMockConnectionResponseHandler,
+} from '../../helpers/MockConnection.js';
+
+import type * as WebauthnModule from '../../../../../front_end/panels/webauthn/webauthn.js';
+import * as SDK from '../../../../../front_end/core/sdk/sdk.js';
+
+const {assert} = chai;
+
+describeWithMockConnection('WebAuthn pane', () => {
+  let Webauthn: typeof WebauthnModule;
+
+  before(async () => {
+    Webauthn = await import('../../../../../front_end/panels/webauthn/webauthn.js');
+  });
+
+  it('disables the large blob checkbox if resident key is disabled', () => {
+    const panel = Webauthn.WebauthnPane.WebauthnPaneImpl.instance();
+    const largeBlob = panel.largeBlobCheckbox;
+    const residentKeys = panel.residentKeyCheckbox;
+
+    if (!largeBlob || !residentKeys) {
+      assert.fail('Required checkbox not found');
+      return;
+    }
+
+    // Make sure resident keys is disabled. Large blob should be disabled and
+    // unchecked.
+    residentKeys.checked = false;
+    residentKeys.dispatchEvent(new Event('change'));
+    assert.isTrue(largeBlob.disabled);
+    assert.isFalse(largeBlob.checked);
+
+    // Enable resident keys. Large blob should be enabled but still not
+    // checked.
+    residentKeys.checked = true;
+    residentKeys.dispatchEvent(new Event('change'));
+    assert.isFalse(largeBlob.disabled);
+    assert.isFalse(largeBlob.checked);
+
+    // Manually check large blob.
+    largeBlob.checked = true;
+    assert.isTrue(largeBlob.checked);
+
+    // Disabling resident keys should reset large blob to disabled and
+    // unchecked.
+    residentKeys.checked = false;
+    residentKeys.dispatchEvent(new Event('change'));
+    assert.isTrue(largeBlob.disabled);
+    assert.isFalse(largeBlob.checked);
+  });
+
+  it('adds an authenticator with large blob option', done => {
+    const target = createTarget();
+    const panel = Webauthn.WebauthnPane.WebauthnPaneImpl.instance();
+    panel.modelAdded(new SDK.WebAuthnModel.WebAuthnModel(target));
+
+    const largeBlob = panel.largeBlobCheckbox;
+    const residentKeys = panel.residentKeyCheckbox;
+
+    if (!largeBlob || !residentKeys) {
+      assert.fail('Required checkbox not found');
+      return;
+    }
+    residentKeys.checked = true;
+    largeBlob.checked = true;
+
+    setMockConnectionResponseHandler('WebAuthn.addVirtualAuthenticator', params => {
+      assert.isTrue(params.options.hasLargeBlob);
+      assert.isTrue(params.options.hasResidentKey);
+      done();
+      return {
+        authenticatorId: 'test',
+      };
+    });
+    panel.addAuthenticatorButton?.click();
+  });
+
+  it('adds an authenticator without the large blob option', done => {
+    const target = createTarget();
+    const panel = Webauthn.WebauthnPane.WebauthnPaneImpl.instance();
+    panel.modelAdded(new SDK.WebAuthnModel.WebAuthnModel(target));
+
+    const largeBlob = panel.largeBlobCheckbox;
+    const residentKeys = panel.residentKeyCheckbox;
+
+    if (!largeBlob || !residentKeys) {
+      assert.fail('Required checkbox not found');
+      return;
+    }
+    residentKeys.checked = true;
+    largeBlob.checked = false;
+
+    setMockConnectionResponseHandler('WebAuthn.addVirtualAuthenticator', params => {
+      assert.isFalse(params.options.hasLargeBlob);
+      assert.isTrue(params.options.hasResidentKey);
+      done();
+      return {
+        authenticatorId: 'test',
+      };
+    });
+    panel.addAuthenticatorButton?.click();
+  });
+});