[WebAuthn] Allow retry on failed enclave PIN attempts for UV

With this change, entering an incorrect GPM PIN for a UV passkey request
will cause the PIN entry dialog to be displayed once again, in order for
the user to retry. The number of consecutive failed PIN attempts is
tracked in a profile pref.

The UI will later be updated to reflect that a PIN re-entry is required.
Also in a coming CL, exceeding the PIN failure maximum will trigger UI
prompting the user to change their PIN.

Bug: 40274370
Change-Id: I9316a17e1ac577ee8bdc0c6cd9e7193da9c358ad
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/5466051
Commit-Queue: Ken Buchanan <[email protected]>
Reviewed-by: Dominic Battre <[email protected]>
Reviewed-by: Adam Langley <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1289882}
diff --git a/chrome/browser/prefs/browser_prefs.cc b/chrome/browser/prefs/browser_prefs.cc
index f324c89..5b82694 100644
--- a/chrome/browser/prefs/browser_prefs.cc
+++ b/chrome/browser/prefs/browser_prefs.cc
@@ -2174,6 +2174,9 @@
   registry->RegisterStringPref(
       webauthn::pref_names::kLastUsedPairingFromSyncPublicKey, "");
 
+  registry->RegisterIntegerPref(
+      webauthn::pref_names::kEnclaveFailedPINAttemptsCount, 0);
+
   side_panel_prefs::RegisterProfilePrefs(registry);
 #endif
 
diff --git a/chrome/browser/webauthn/enclave_authenticator_browsertest.cc b/chrome/browser/webauthn/enclave_authenticator_browsertest.cc
index a3b7364d..ef75b8e7 100644
--- a/chrome/browser/webauthn/enclave_authenticator_browsertest.cc
+++ b/chrome/browser/webauthn/enclave_authenticator_browsertest.cc
@@ -33,10 +33,12 @@
 #include "chrome/browser/webauthn/gpm_enclave_controller.h"
 #include "chrome/browser/webauthn/passkey_model_factory.h"
 #include "chrome/browser/webauthn/test_util.h"
+#include "chrome/browser/webauthn/webauthn_pref_names.h"
 #include "chrome/test/base/in_process_browser_test.h"
 #include "chrome/test/base/ui_test_utils.h"
 #include "components/network_session_configurator/common/network_switches.h"
 #include "components/os_crypt/sync/os_crypt_mocker.h"
+#include "components/prefs/pref_service.h"
 #include "components/signin/public/identity_manager/account_info.h"
 #include "components/signin/public/identity_manager/identity_test_environment.h"
 #include "components/sync/service/sync_service.h"
@@ -142,6 +144,16 @@
            e => window.domAutomationController.send('error ' + e));
 })())";
 
+static constexpr char kGetAssertionUvRequired[] = R"((() => {
+  return navigator.credentials.get({ publicKey: {
+    challenge: new Uint8Array([0]),
+    timeout: 10000,
+    userVerification: 'required',
+    allowCredentials: [],
+  }}).then(c => window.domAutomationController.send('webauthn: OK'),
+           e => window.domAutomationController.send('error ' + e));
+})())";
+
 struct TempDir {
  public:
   TempDir() { CHECK(dir_.CreateUniqueTempDir()); }
@@ -215,7 +227,10 @@
     }
     virtual ~DelegateObserver() = default;
 
-    void WaitForUI() { run_loop_->Run(); }
+    void WaitForUI() {
+      run_loop_->Run();
+      run_loop_ = std::make_unique<base::RunLoop>();
+    }
 
     void SetPendingTrustedVaultConnection(
         std::unique_ptr<trusted_vault::TrustedVaultConnection> connection) {
@@ -802,4 +817,87 @@
   EXPECT_EQ(script_result, "\"webauthn: OK\"");
 }
 
+IN_PROC_BROWSER_TEST_F(EnclaveAuthenticatorBrowserTest,
+                       RegisterDeviceWithGpmPin_UVRequestsWithWrongPIN) {
+  /* Test script:
+   *  - Prerequisites:
+   *       Enclave not registered
+   *       No existing security domain secrets
+   *       Existing user account with password sync enabled
+   *       Platform UV unavailable
+   * 1.  Modal MakeCredential request invoked by RP, requires UV.
+   * 2.  Mechanism selection appears; test chooses enclave.
+   * 3.  UI for onboarding appears; test accepts it
+   * 4.  Test selects a GPM PIN
+   * 5.  Device registration with enclave succeeds
+   * 6.  MakeCredential succeeds
+   * 7.  Modal GetAssertion request invoked by RP, requires UV.
+   * 9.  Test enters wrong PIN
+   * 10. PIN entry dialog appears again, test enters correct PIN.
+   *
+   * Notably, user verification is asserted without a second GPM PIN prompt.
+   */
+  trusted_vault::DownloadAuthenticationFactorsRegistrationStateResult
+      registration_state_result;
+  registration_state_result.state = trusted_vault::
+      DownloadAuthenticationFactorsRegistrationStateResult::State::kEmpty;
+  SetMockVaultConnectionOnRequestDelegate(std::move(registration_state_result));
+
+  content::WebContents* web_contents =
+      browser()->tab_strip_model()->GetActiveWebContents();
+  content::DOMMessageQueue message_queue(web_contents);
+  content::ExecuteScriptAsync(web_contents, kMakeCredentialUvRequired);
+  delegate_observer()->WaitForUI();
+
+  EXPECT_EQ(dialog_model()->step(),
+            AuthenticatorRequestDialogModel::Step::kMechanismSelection);
+  EXPECT_EQ(request_delegate()
+                ->enclave_controller_for_testing()
+                ->account_state_for_testing(),
+            GPMEnclaveController::AccountState::kEmpty);
+  model_observer()->SetStepToObserve(
+      AuthenticatorRequestDialogController::Step::kGPMOnboarding);
+  SimulateEnclaveMechanismSelection();
+  model_observer()->WaitForStep();
+
+  dialog_model()->OnGPMOnboardingAccepted();
+  EXPECT_EQ(dialog_model()->step(),
+            AuthenticatorRequestDialogModel::Step::kGPMCreatePin);
+  dialog_model()->OnGPMPinEntered(u"123456");
+
+  std::string script_result;
+  ASSERT_TRUE(message_queue.WaitForMessage(&script_result));
+  EXPECT_EQ(script_result, "\"webauthn: uv=true\"");
+
+  content::ExecuteScriptAsync(web_contents, kGetAssertionUvRequired);
+  delegate_observer()->WaitForUI();
+
+  EXPECT_EQ(dialog_model()->step(),
+            AuthenticatorRequestDialogModel::Step::kSelectPriorityMechanism);
+  model_observer()->SetStepToObserve(
+      AuthenticatorRequestDialogController::Step::kGPMEnterPin);
+  dialog_model()->OnUserConfirmedPriorityMechanism();
+  model_observer()->WaitForStep();
+
+  EXPECT_EQ(browser()->profile()->GetPrefs()->GetInteger(
+                webauthn::pref_names::kEnclaveFailedPINAttemptsCount),
+            0);
+  model_observer()->SetStepToObserve(
+      AuthenticatorRequestDialogController::Step::kGPMEnterPin);
+  dialog_model()->OnGPMPinEntered(u"111111");
+  model_observer()->WaitForStep();
+
+  EXPECT_EQ(browser()->profile()->GetPrefs()->GetInteger(
+                webauthn::pref_names::kEnclaveFailedPINAttemptsCount),
+            1);
+  dialog_model()->OnGPMPinEntered(u"123456");
+
+  ASSERT_TRUE(message_queue.WaitForMessage(&script_result));
+  EXPECT_EQ(script_result, "\"webauthn: OK\"");
+
+  EXPECT_EQ(browser()->profile()->GetPrefs()->GetInteger(
+                webauthn::pref_names::kEnclaveFailedPINAttemptsCount),
+            0);
+}
+
 #endif  // !defined(MEMORY_SANITIZER)
diff --git a/chrome/browser/webauthn/gpm_enclave_controller.cc b/chrome/browser/webauthn/gpm_enclave_controller.cc
index 6e8fec7..9629b09 100644
--- a/chrome/browser/webauthn/gpm_enclave_controller.cc
+++ b/chrome/browser/webauthn/gpm_enclave_controller.cc
@@ -13,7 +13,9 @@
 #include "chrome/browser/webauthn/enclave_manager_factory.h"
 #include "chrome/browser/webauthn/passkey_model_factory.h"
 #include "chrome/browser/webauthn/proto/enclave_local_state.pb.h"
+#include "chrome/browser/webauthn/webauthn_pref_names.h"
 #include "components/device_event_log/device_event_log.h"
+#include "components/prefs/pref_service.h"
 #include "components/signin/public/identity_manager/identity_manager.h"
 #include "components/signin/public/identity_manager/primary_account_access_token_fetcher.h"
 #include "components/trusted_vault/frontend_trusted_vault_connection.h"
@@ -426,6 +428,7 @@
 
   have_added_device_ = true;
   SetAccountStateReady();
+  SetFailedPINAttemptCount(0);
 
   switch (account_state_) {
     case AccountState::kReady:
@@ -570,6 +573,8 @@
 }
 
 void GPMEnclaveController::PromptForPin() {
+  // TODO(enclave): Check for failed PIN attempts having been exhausted, and
+  // trigger a PIN reset flow in that case.
   model_->SetStep(pin_is_arbitrary_ ? Step::kGPMEnterArbitraryPin
                                     : Step::kGPMEnterPin);
 }
@@ -709,6 +714,9 @@
           enclave_manager_->HardwareKeySigningCallback();
       CHECK(claimed_pin);
       request->claimed_pin = std::move(claimed_pin);
+      request->pin_result_callback =
+          base::BindOnce(&GPMEnclaveController::HandlePINValidationResult,
+                         weak_ptr_factory_.GetWeakPtr());
       break;
 
     case EnclaveUserVerificationMethod::kUVKeyWithChromeUI:
@@ -786,3 +794,34 @@
       PasskeyModelFactory::GetInstance()->GetForProfile(GetProfile());
   passkey_model->CreatePasskey(passkey);
 }
+
+bool GPMEnclaveController::GetFailedPINAttemptCount() {
+  return GetProfile()->GetPrefs()->GetInteger(
+      webauthn::pref_names::kEnclaveFailedPINAttemptsCount);
+}
+
+void GPMEnclaveController::SetFailedPINAttemptCount(int count) {
+  GetProfile()->GetPrefs()->SetInteger(
+      webauthn::pref_names::kEnclaveFailedPINAttemptsCount, count);
+}
+
+void GPMEnclaveController::HandlePINValidationResult(
+    device::enclave::PINValidationResult result) {
+  switch (result) {
+    case device::enclave::PINValidationResult::kSuccess:
+      SetFailedPINAttemptCount(0);
+      break;
+    case device::enclave::PINValidationResult::kIncorrect: {
+      int count = GetFailedPINAttemptCount();
+      // TODO(enclave): If the new count is 5, go to the locked PIN UI.
+      SetFailedPINAttemptCount(++count);
+      // TODO(enclave): This needs better UI, but for now just pops up PIN
+      // entry again.
+      model_->SetStep(Step::kGPMEnterPin);
+      break;
+    }
+    case device::enclave::PINValidationResult::kLocked:
+      // TODO(enclave): Handle locked PIN.
+      break;
+  }
+}
diff --git a/chrome/browser/webauthn/gpm_enclave_controller.h b/chrome/browser/webauthn/gpm_enclave_controller.h
index c45627a..03b1b867 100644
--- a/chrome/browser/webauthn/gpm_enclave_controller.h
+++ b/chrome/browser/webauthn/gpm_enclave_controller.h
@@ -154,6 +154,15 @@
   // Invoked when a new GPM passkey is created, to save it to sync data.
   void OnPasskeyCreated(sync_pb::WebauthnCredentialSpecifics passkey);
 
+  // Accessors for the profile pref that counts the number of consecutive failed
+  // PIN attempts to know when a lockout will happen.
+  bool GetFailedPINAttemptCount();
+  void SetFailedPINAttemptCount(int count);
+
+  // Invoked when a passkey request has been sent to the enclave service with
+  // PIN UV, and the request succeeded or a PIN validation error occurred.
+  void HandlePINValidationResult(device::enclave::PINValidationResult type);
+
   const content::GlobalRenderFrameHostId render_frame_host_id_;
   const std::string rp_id_;
   const device::FidoRequestType request_type_;
diff --git a/chrome/browser/webauthn/webauthn_pref_names.cc b/chrome/browser/webauthn/webauthn_pref_names.cc
index dbc604cf..a9fb03c 100644
--- a/chrome/browser/webauthn/webauthn_pref_names.cc
+++ b/chrome/browser/webauthn/webauthn_pref_names.cc
@@ -6,12 +6,15 @@
 
 namespace webauthn::pref_names {
 
-const char kRemoteProxiedRequestsAllowed[] =
-    "webauthn.remote_proxied_requests_allowed";
-
 const char kAllowWithBrokenCerts[] = "webauthn.allow_with_broken_certs";
 
+const char kEnclaveFailedPINAttemptsCount[] =
+    "webauthn.enclave_failed_pin_attempts_count";
+
 extern const char kLastUsedPairingFromSyncPublicKey[] =
     "webauthn.last_used_pairing_from_sync_public_key";
 
+const char kRemoteProxiedRequestsAllowed[] =
+    "webauthn.remote_proxied_requests_allowed";
+
 }  // namespace webauthn::pref_names
diff --git a/chrome/browser/webauthn/webauthn_pref_names.h b/chrome/browser/webauthn/webauthn_pref_names.h
index 11d3acee..7a144e7 100644
--- a/chrome/browser/webauthn/webauthn_pref_names.h
+++ b/chrome/browser/webauthn/webauthn_pref_names.h
@@ -7,18 +7,22 @@
 
 namespace webauthn::pref_names {
 
-// Maps to the WebAuthenticationRemoteProxiedRequestsAllowed enterprise
-// policy.
-extern const char kRemoteProxiedRequestsAllowed[];
-
 // Maps to the AllowWebAuthnWithBrokenCerts enterprise policy.
 extern const char kAllowWithBrokenCerts[];
 
+// Tracks how many consecutive failed GPM PIN attempts have been made to the
+// enclave service from this device and profile.
+extern const char kEnclaveFailedPINAttemptsCount[];
+
 // The most recently used phone pairing from sync, identified by its public key
 // encoded in base64. If there is no last recently used phone, the preference
 // will be an empty string.
 extern const char kLastUsedPairingFromSyncPublicKey[];
 
+// Maps to the WebAuthenticationRemoteProxiedRequestsAllowed enterprise
+// policy.
+extern const char kRemoteProxiedRequestsAllowed[];
+
 }  // namespace webauthn::pref_names
 
 #endif  // CHROME_BROWSER_WEBAUTHN_WEBAUTHN_PREF_NAMES_H_