CVE-2026-106264
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcomponents/webauthn/ios/passkey_java_script_feature.mm |
modified | |
WebauthnCredentialSpecificscomponents/webauthn/ios/passkey_tab_helper.h |
modified | |
Origincomponents/webauthn/ios/passkey_tab_helper.h |
modified | |
WebFramecomponents/webauthn/ios/passkey_tab_helper.h |
modified | |
ifcomponents/webauthn/ios/passkey_tab_helper.mm |
modified | |
forcomponents/webauthn/ios/passkey_tab_helper.mm |
modified | |
GURLios/chrome/browser/passwords/model/ios_chrome_password_manager_client.h |
modified | |
ProfileIOSios/chrome/browser/passwords/model/ios_chrome_password_manager_client.h |
modified | |
PasskeyTabHelperios/chrome/browser/passwords/model/ios_chrome_password_manager_client.h |
modified | |
ifios/chrome/browser/passwords/model/ios_chrome_password_manager_client.mm |
modified | |
GetPasskeyTabHelperios/chrome/browser/passwords/model/ios_chrome_password_manager_client.mm |
modified |
Files Changed
components/webauthn/ios/passkey_tab_helper.hcomponents/webauthn/ios/passkey_java_script_feature.mmios/chrome/browser/passwords/model/ios_chrome_password_manager_client.mmcomponents/webauthn/ios/passkey_tab_helper.mmcomponents/webauthn/ios/passkey_tab_helper_unittest.mmios/chrome/browser/passwords/model/ios_chrome_password_manager_client.hios/chrome/browser/passwords/model/ios_chrome_password_manager_client_unittest.mm
Patch
From caa95464461b91a2137ada34aacf843b5daaace9 Mon Sep 17 00:00:00 2001 From: Alexis Hétu <[email protected]> Date: Fri, 11 Sep 2026 12:10:35 -0700 Subject: [PATCH] [iOS] Harden conditional passkey creation checks Hardens iOS automatic passkey upgrade (conditional creation) checks: - Requires tab-scoped password login eligibility for conditional create requests without a user gesture. - Records successful password logins in IOSChromePasswordManagerClient to establish single-use automatic passkey upgrade eligibility. - Evaluates credential recency using the most recent of date_created, date_last_filled, and date_last_used against the 5-minute threshold. - Rejects credentials marked as blocked_by_user. - Ensures conditional create requests without eligibility fail silently and defer to the renderer without triggering UI. Bug: 533084756 Change-Id: I83c4193cafec48d7c692be8799fa84d8c51a937b Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8374522 Commit-Queue: Alexis Hétu <[email protected]> Reviewed-by: Tommy Martino <[email protected]> Cr-Commit-Position: refs/heads/main@{#1696500} --- diff --git a/components/webauthn/ios/passkey_java_script_feature.mm b/components/webauthn/ios/passkey_java_script_feature.mm index 5a4ef356..f0073921 100644 --- a/components/webauthn/ios/passkey_java_script_feature.mm +++ b/components/webauthn/ios/passkey_java_script_feature.mm @@ -89,19 +89,57 @@ return output; } -// Validates whether the given passkey request is permitted to proceed. -bool ValidateFeatureUsage(const PasskeyRequestParams& request_params, - bool has_user_interaction) { - if (request_params.Type() == PasskeyRequestParams::RequestType::kModal) { - // Modal passkey registration or assertion is only allowed if it originates - // from a user gesture. - if (!has_user_interaction) { - return false; +// Validates whether the given passkey registration (create) request is +// permitted to proceed. +bool ValidateFeatureUsageForCreate( + const RegistrationRequestParams& request_params, + bool has_user_interaction, + PasskeyTabHelper* passkey_tab_helper) { + switch (request_params.Type()) { + case PasskeyRequestParams::RequestType::kModal: + // Modal passkey registration is only allowed if it originates from a user + // gesture. + if (!has_user_interaction) { + return false; + } + return base::FeatureList::IsEnabled(kIOSPasskeyModalLoginWithShim); + case PasskeyRequestParams::RequestType::kConditionalCreate: { + // Conditional create is only allowed if there is user interaction or if + // the tab has eligible automatic passkey upgrade credentials from a + // recent password login. + bool is_user_authenticated = + has_user_interaction || + (passkey_tab_helper && + passkey_tab_helper->HasAutomaticPasskeyUpgradeEligibility( + request_params)); + if (!is_user_authenticated) { + return false; + } + return base::FeatureList::IsEnabled(kIOSPasskeyConditionalLoginWithShim); } + case PasskeyRequestParams::RequestType::kConditionalGet: + case PasskeyRequestParams::RequestType::kUnknown: + return false; + } +} - return base::FeatureList::IsEnabled(kIOSPasskeyModalLoginWithShim); - } else { - return base::FeatureList::IsEnabled(kIOSPasskeyConditionalLoginWithShim); +// Validates whether the given passkey assertion (get) request is permitted to +// proceed. +bool ValidateFeatureUsageForGet(const AssertionRequestParams& request_params, + bool has_user_interaction) { + switch (request_params.Type()) { + case PasskeyRequestParams::RequestType::kModal: + // Modal passkey assertion is only allowed if it originates from a user + // gesture. + if (!has_user_interaction) { + return false; + } + return base::FeatureList::IsEnabled(kIOSPasskeyModalLoginWithShim); + case PasskeyRequestParams::RequestType::kConditionalGet: + return base::FeatureList::IsEnabled(kIOSPasskeyConditionalLoginWithShim); + case PasskeyRequestParams::RequestType::kConditionalCreate: + case PasskeyRequestParams::RequestType::kUnknown: + return false; } } @@ -389,8 +427,9 @@ return; } - if (!ValidateFeatureUsage(*registration_request_params, - message.is_user_interacting())) { + if (!ValidateFeatureUsageForCreate(*registration_request_params, + message.is_user_interacting(), + passkey_tab_helper)) { // TODO(crbug.com/460485333): Log the error. passkey_tab_helper->DeferToRenderer(std::move(*request_info), registration_request_params->Type()); @@ -414,8 +453,8 @@ return; } - if (!ValidateFeatureUsage(*assertion_request_params, - message.is_user_interacting())) { + if (!ValidateFeatureUsageForGet(*assertion_request_params, + message.is_user_interacting())) { // TODO(crbug.com/460485333): Log the error. passkey_tab_helper->DeferToRenderer(std::move(*request_info), assertion_request_params->Type()); diff --git a/components/webauthn/ios/passkey_tab_helper.h b/components/webauthn/ios/passkey_tab_helper.h index dfc95c2c..fc0ae37 100644 --- a/components/webauthn/ios/passkey_tab_helper.h +++ b/components/webauthn/ios/passkey_tab_helper.h @@ -28,6 +28,10 @@ class WebauthnCredentialSpecifics; } // namespace sync_pb +namespace url { +class Origin; +} // namespace url + namespace web { class WebFrame; } // namespace web @@ -101,6 +105,22 @@ bool HasCredential(const std::string& rp_id, const std::string& credential_id) const; + // Records a successful password login for the current web state to establish + // automatic passkey upgrade eligibility. + void RecordPasswordLogin(std::string_view username, + const url::Origin& origin); + + // Returns whether this tab has recorded a recent, unconsumed password login + // matching the username and relying party of `params`, and the automatic + // passkey upgrade feature is enabled. + // + // This is a synchronous, read-only check used early during renderer IPC + // validation (`PasskeyJavaScriptFeature`) to permit conditional passkey + // creation without a user gesture before querying the password store. + // Does not consume eligibility or inspect stored credentials. + bool HasAutomaticPasskeyUpgradeEligibility( + const RegistrationRequestParams& params) const; + // Requests a passkey to be created given the provided request ID. Fetches the // shared keys list and calls the CompletePasskeyCreation callback. // TODO(crbug.com/460485333): Test passkey creation flow. @@ -241,10 +261,16 @@ AssertionRequestParams params, IOSWebAuthnCredentialsDelegate* delegate); - // Whether automatic passkey upgrade is allowed. + // Returns whether automatic passkey upgrade is permitted after querying the + // `PasswordStore`. + // + // Verifies `HasAutomaticPasskeyUpgradeEligibility(params)` and checks that + // `logins` contains an unblocked password credential matching the user and + // domain active within `kPasskeyUpgradeRecencyThreshold`. If eligible, marks + // the tab's eligibility as consumed (single-use) and returns true. bool CanPerformAutomaticPasskeyUpgrade( const RegistrationRequestParams& params, - const std::vector<password_manager::StoredCredential>& logins) const; + const std::vector<password_manager::StoredCredential>& logins); // Handles passkey registration requests after it passes validation. void HandleRegistration(RegistrationRequestParams params); @@ -364,6 +390,16 @@ // Flag to avoid duplicate queries to the password store. bool is_querying_password_store_ = false; + // Stores eligibility for automatic passkey upgrade scoped to this tab. + struct AutomaticUpgradeEligibility { + std::string username; + std::string domain_rp_id; + base::TimeTicks timestamp; + bool consumed = false; + }; + + std::optional<AutomaticUpgradeEligibility> automatic_upgrade_eligibility_; + // This is necessary because this object could be deleted during any callback, // and we don't want to risk a UAF if that happens. base::WeakPtrFactory<PasskeyTabHelper> weak_factory_{this}; diff --git a/components/webauthn/ios/passkey_tab_helper.mm b/components/webauthn/ios/passkey_tab_helper.mm index 345e91a..a101e5a 100644 --- a/components/webauthn/ios/passkey_tab_helper.mm +++ b/components/webauthn/ios/passkey_tab_helper.mm @@ -4,6 +4,7 @@ #import "components/webauthn/ios/passkey_tab_helper.h" +#include <algorithm> #include <tuple> #import "base/check_deref.h" @@ -35,6 +36,7 @@ #import "ios/web/public/web_state.h" #import "net/base/registry_controlled_domains/registry_controlled_domain.h" #import "services/network/public/cpp/shared_url_loader_factory.h" +#import "url/origin.h" namespace webauthn { @@ -493,26 +495,66 @@ MaybeShowInterstitialAndRegister(std::move(params)); } +void PasskeyTabHelper::RecordPasswordLogin(std::string_view username, + const url::Origin& origin) { + if (origin.opaque()) { + return; + } + automatic_upgrade_eligibility_ = AutomaticUpgradeEligibility{ + .username = std::string(username), + .domain_rp_id = GetDomainAndRegistryOrHost(origin.host()), + .timestamp = base::TimeTicks::Now(), + .consumed = false, + }; +} + +bool PasskeyTabHelper::HasAutomaticPasskeyUpgradeEligibility( + const RegistrationRequestParams& params) const { + if (!client_->IsAutomaticPasskeyUpgradeEnabled()) { + return false; + } + if (!automatic_upgrade_eligibility_.has_value()) { + return false; + } + if (automatic_upgrade_eligibility_->consumed) { + return false; + } + base::TimeDelta elapsed = + base::TimeTicks::Now() - automatic_upgrade_eligibility_->timestamp; + if (elapsed.is_negative() || elapsed > kPasskeyUpgradeRecencyThreshold) { + return false; + } + std::string username = params.UserEntity().name; + std::string domain_rp_id = GetDomainAndRegistryOrHost(params.RpId()); + return automatic_upgrade_eligibility_->username == username && + automatic_upgrade_eligibility_->domain_rp_id == domain_rp_id; +} + // NOTE: If you change the domain matching logic in this method, please also // update the corresponding logic in // ios/chrome/credential_provider_extension/passkey_request_details.mm // (hasMatchingPassword:). bool PasskeyTabHelper::CanPerformAutomaticPasskeyUpgrade( const RegistrationRequestParams& params, - const std::vector<password_manager::StoredCredential>& logins) const { - if (!client_->IsAutomaticPasskeyUpgradeEnabled()) { + const std::vector<password_manager::StoredCredential>& logins) { + if (!HasAutomaticPasskeyUpgradeEligibility(params)) { return false; } + std::string username = params.UserEntity().name; std::string domain_rp_id = GetDomainAndRegistryOrHost(params.RpId()); for (const password_manager::StoredCredential& form : logins) { - if (base::UTF16ToUTF8(form.username_value) == username && + if (!form.blocked_by_user && + base::UTF16ToUTF8(form.username_value) == username && GetDomainAndRegistryOrHost(form.url.host()) == domain_rp_id) { + base::Time most_recent_time = std::max( + {form.date_created, form.date_last_filled, form.date_last_used}); base::TimeDelta time_since_last_use = - base::Time::Now() - form.date_last_used; + base::Time::Now() - most_recent_time; if (!time_since_last_use.is_negative() && time_since_last_use <= kPasskeyUpgradeRecencyThreshold) { + automatic_upgrade_eligibility_->consumed = true; return true; } } diff --git a/ios/chrome/browser/passwords/model/ios_chrome_password_manager_client.h b/ios/chrome/browser/passwords/model/ios_chrome_password_manager_client.h index 63af88b..ebf8ec7 100644 --- a/ios/chrome/browser/passwords/model/ios_chrome_password_manager_client.h +++ b/ios/chrome/browser/passwords/model/ios_chrome_password_manager_client.h @@ -32,6 +32,7 @@ #import "ios/web/public/web_state_observer.h" #import "services/metrics/public/cpp/ukm_source_id.h" +class GURL; class ProfileIOS; namespace device_reauth { @@ -52,6 +53,10 @@ enum class WarningAction; } +namespace webauthn { +class PasskeyTabHelper; +} + @protocol IOSChromePasswordManagerClientBridge <PasswordManagerClientBridge> @property(readonly, nonatomic) ProfileIOS* profile; @@ -191,6 +196,14 @@ override; private: + // Returns the PasskeyTabHelper for the associated web state, or nullptr. + webauthn::PasskeyTabHelper* GetPasskeyTabHelper() const; + + // Records a successful password login on the associated web state from + // `form_manager` to establish automatic passkey upgrade eligibility. + void RecordPasswordLogin( + const password_manager::PasswordFormManagerForUI* form_manager); + __weak id<IOSChromePasswordManagerClientBridge> bridge_; password_manager::PasswordFeatureManagerImpl password_feature_manager_; diff --git a/ios/chrome/browser/passwords/model/ios_chrome_password_manager_client.mm b/ios/chrome/browser/passwords/model/ios_chrome_password_manager_client.mm index 8aa1d6ba..1f07c0e2 100644 --- a/ios/chrome/browser/passwords/model/ios_chrome_password_manager_client.mm +++ b/ios/chrome/browser/passwords/model/ios_chrome_password_manager_client.mm @@ -40,6 +40,7 @@ #import "components/ukm/ios/ukm_url_recorder.h" #import "components/webauthn/ios/features.h" #import "components/webauthn/ios/ios_webauthn_credentials_delegate_factory.h" +#import "components/webauthn/ios/passkey_tab_helper.h" #import "ios/chrome/browser/device_reauth/model/ios_device_authenticator.h" #import "ios/chrome/browser/device_reauth/model/ios_device_authenticator_factory.h" #import "ios/chrome/browser/device_reauth/model/reauthentication_service.h" @@ -63,12 +64,14 @@ #import "ios/chrome/browser/signin/model/identity_manager_factory.h" #import "ios/chrome/browser/sync/model/sync_service_factory.h" #import "ios/chrome/browser/translate/model/chrome_ios_translate_client.h" +#import "ios/web/public/web_state.h" #import "net/cert/cert_status_flags.h" #import "services/metrics/public/cpp/metrics_utils.h" #import "services/metrics/public/cpp/ukm_builders.h" #import "services/metrics/public/cpp/ukm_recorder.h" #import "services/network/public/cpp/shared_url_loader_factory.h" #import "url/gurl.h" +#import "url/origin.h" using password_manager::PasswordFormManagerForUI; using password_manager::PasswordManagerMetricsRecorder; @@ -135,6 +138,8 @@ return false; } + RecordPasswordLogin(form_to_save.get()); + [bridge_ removePasswordInfoBarManualFallback:YES]; if (update_password) { @@ -176,6 +181,8 @@ void IOSChromePasswordManagerClient::AutomaticPasswordSave( std::unique_ptr<PasswordFormManagerForUI> saved_form_manager, bool is_update_confirmation) { + RecordPasswordLogin(saved_form_manager.get()); + if (base::FeatureList::IsEnabled(kPasswordSavedInfobar)) { [bridge_ showPasswordSavedInfoBar:std::move(saved_form_manager)]; } @@ -264,6 +271,7 @@ void IOSChromePasswordManagerClient::NotifySuccessfulLoginWithExistingPassword( std::unique_ptr<password_manager::PasswordFormManagerForUI> submitted_manager) { + RecordPasswordLogin(submitted_manager.get()); helper_.NotifySuccessfulLoginWithExistingPassword( std::move(submitted_manager)); [bridge_ @@ -520,3 +528,30 @@ return &autofill_client->GetCrowdsourcingManager(); } + +webauthn::PasskeyTabHelper* +IOSChromePasswordManagerClient::GetPasskeyTabHelper() const { + web::WebState* web_state = bridge_.webState; + if (!web_state) { + return nullptr; + } + return webauthn::PasskeyTabHelper::FromWebState(web_state); +} + +void IOSChromePasswordManagerClient::RecordPasswordLogin( + const password_manager::PasswordFormManagerForUI* form_manager) { + if (!form_manager) { + return; + } + const auto& credentials = form_manager->GetPendingCredentials(); + url::Origin origin = url::Origin::Create(credentials.url); + if (origin.opaque()) { + return; + } + auto* passkey_tab_helper = GetPasskeyTabHelper(); ... (file diff truncated)
Regression Test / PoC
diff --git a/components/webauthn/ios/passkey_tab_helper_unittest.mm b/components/webauthn/ios/passkey_tab_helper_unittest.mm
index 0654abc..bcb46b0 100644
--- a/components/webauthn/ios/passkey_tab_helper_unittest.mm
+++ b/components/webauthn/ios/passkey_tab_helper_unittest.mm
@@ -39,6 +39,7 @@
#import "services/network/test/test_url_loader_factory.h"
#import "testing/platform_test.h"
#import "third_party/ocmock/OCMock/OCMock.h"
+#import "url/origin.h"
namespace webauthn {
@@ -62,6 +63,7 @@
constexpr char kOriginURL[] = "https://example.com";
constexpr char kInsecureOriginURL[] = "http://example.com";
constexpr char kRelatedOriginURL[] = "https://example.ca";
+constexpr char kSubdomainOriginURL[] = "https://sub.example.com";
constexpr char16_t kDeferToRendererJsCall[] = u"deferToRenderer";
constexpr char kWebAuthenticationIOSContentAreaEventHistogram[] =
@@ -279,7 +281,8 @@
std::optional<bool>(expected_without_biometrics));
}
- web::WebTaskEnvironment task_environment_;
+ web::WebTaskEnvironment task_environment_{
+ web::WebTaskEnvironment::TimeSource::MOCK_TIME};
web::ScopedTestingWebClient scoped_web_client_;
base::HistogramTester histogram_tester_;
std::unique_ptr<PasskeyModel> passkey_model_ =
@@ -725,6 +728,9 @@
// Tests that automatic passkey upgrade is allowed for a valid, recent login.
TEST_F(PasskeyTabHelperTest, AutomaticPasskeyUpgradeSuccess) {
+ passkey_tab_helper()->RecordPasswordLogin(
+ "", url::Origin::Create(GURL(kOriginURL)));
+
password_manager::PasswordForm form;
form.username_value = u"";
form.url = GURL(kOriginURL);
@@ -740,6 +746,9 @@
// Tests that automatic passkey upgrade is denied if the login is too old.
TEST_F(PasskeyTabHelperTest, AutomaticPasskeyUpgradeThresholdEnforcement) {
+ passkey_tab_helper()->RecordPasswordLogin(
+ "", url::Origin::Create(GURL(kOriginURL)));
+
password_manager::PasswordForm form;
form.username_value = u"";
form.url = GURL(kOriginURL);
@@ -755,6 +764,48 @@
// Tests that automatic passkey upgrade is denied if no logins are found.
TEST_F(PasskeyTabHelperTest, AutomaticPasskeyUpgradeRemovalHandling) {
+ passkey_tab_helper()->RecordPasswordLogin(
+ "", url::Origin::Create(GURL(kOriginURL)));
+
+ password_manager::PasswordForm form;
+ form.username_value = u"";
+ form.url = GURL(kOriginURL);
+ form.date_last_used = base::Time::Now();
+
+ RegistrationRequestParams params = BuildRegistrationRequestParams({});
+
+ std::vector<password_manager::PasswordForm> empty_results;
+ EXPECT_FALSE(CanPerformAutomaticPasskeyUpgrade(params, empty_results));
+
+ std::vector<password_manager::PasswordForm> results;
+ results.push_back(form);
+ EXPECT_TRUE(CanPerformAutomaticPasskeyUpgrade(params, results));
+}
+
+// Tests that automatic passkey upgrade allows matching subdomains via eTLD+1.
+TEST_F(PasskeyTabHelperTest, AutomaticPasskeyUpgradeRpIdNormalization) {
+ passkey_tab_helper()->RecordPasswordLogin(
+ "", url::Origin::Create(GURL(kSubdomainOriginURL)));
+
+ password_manager::PasswordForm form;
+ form.username_value = u"";
+ form.url = GURL(kSubdomainOriginURL);
+ form.date_last_used = base::Time::Now();
+
+ std::vector<password_manager::PasswordForm> results;
+ results.push_back(form);
+
+ RegistrationRequestParams params = BuildRegistrationRequestParams({});
+
+ EXPECT_TRUE(CanPerformAutomaticPasskeyUpgrade(params, results));
+}
+
+// Tests that automatic passkey upgrade eligibility is single-use and consumed
+// after a successful upgrade evaluation.
+TEST_F(PasskeyTabHelperTest, AutomaticPasskeyUpgradeSingleUse) {
+ passkey_tab_helper()->RecordPasswordLogin(
+ "", url::Origin::Create(GURL(kOriginURL)));
+
password_manager::PasswordForm form;
form.username_value = u"";
form.url = GURL(kOriginURL);
@@ -766,16 +817,16 @@
RegistrationRequestParams params = BuildRegistrationRequestParams({});
EXPECT_TRUE(CanPerformAutomaticPasskeyUpgrade(params, results));
-
- results.clear();
+ // The second attempt must fail because the eligibility was consumed.
EXPECT_FALSE(CanPerformAutomaticPasskeyUpgrade(params, results));
}
-// Tests that automatic passkey upgrade allows matching subdomains via eTLD+1.
-TEST_F(PasskeyTabHelperTest, AutomaticPasskeyUpgradeRpIdNormalization) {
+// Tests that automatic passkey upgrade is denied if the tab did not record a
+// password login, even if matching credentials exist in the password store.
+TEST_F(PasskeyTabHelperTest, AutomaticPasskeyUpgradeRequiresTabEligibility) {
password_manager::PasswordForm form;
form.username_value = u"";
- form.url = GURL("https://sub.example.com");
+ form.url = GURL(kOriginURL);
form.date_last_used = base::Time::Now();
std::vector<password_manager::PasswordForm> results;
@@ -783,12 +834,66 @@
RegistrationRequestParams params = BuildRegistrationRequestParams({});
- EXPECT_TRUE(CanPerformAutomaticPasskeyUpgrade(params, results));
+ EXPECT_FALSE(CanPerformAutomaticPasskeyUpgrade(params, results));
+}
+
+// Tests that automatic passkey upgrade tab eligibility expires after the
+// recency threshold.
+TEST_F(PasskeyTabHelperTest, AutomaticPasskeyUpgradeExpiredTabEligibility) {
+ passkey_tab_helper()->RecordPasswordLogin(
+ "", url::Origin::Create(GURL(kOriginURL)));
+ task_environment_.FastForwardBy(base::Minutes(6));
+
+ password_manager::PasswordForm form;
+ form.username_value = u"";
+ form.url = GURL(kOriginURL);
+ form.date_last_used = base::Time::Now();
+
+ std::vector<password_manager::PasswordForm> results;
+ results.push_back(form);
+
+ RegistrationRequestParams params = BuildRegistrationRequestParams({});
+
+ EXPECT_FALSE(CanPerformAutomaticPasskeyUpgrade(params, results));
+}
+
+// Tests HasAutomaticPasskeyUpgradeEligibility directly under various
+// conditions.
+TEST_F(PasskeyTabHelperTest, HasAutomaticPasskeyUpgradeEligibility) {
+ RegistrationRequestParams params = BuildRegistrationRequestParams({});
+
+ // No login recorded -> not eligible.
+ EXPECT_FALSE(
+ passkey_tab_helper()->HasAutomaticPasskeyUpgradeEligibility(params));
+
+ // Record login -> eligible.
+ passkey_tab_helper()->RecordPasswordLogin(
+ "", url::Origin::Create(GURL(kOriginURL)));
+ EXPECT_TRUE(
+ passkey_tab_helper()->HasAutomaticPasskeyUpgradeEligibility(params));
+
+ // Forwarding past threshold -> expired.
+ task_environment_.FastForwardBy(base::Minutes(6));
+ EXPECT_FALSE(
+ passkey_tab_helper()->HasAutomaticPasskeyUpgradeEligibility(params));
+}
+
+// Tests that recording a password login with an opaque origin does not
+// establish automatic passkey upgrade eligibility.
+TEST_F(PasskeyTabHelperTest, AutomaticPasskeyUpgradeOpaqueOriginDenied) {
+ RegistrationRequestParams params = BuildRegistrationRequestParams({});
+
+ passkey_tab_helper()->RecordPasswordLogin("", url::Origin());
+ EXPECT_FALSE(
+ passkey_tab_helper()->HasAutomaticPasskeyUpgradeEligibility(params));
}
// Tests that automatic passkey upgrade is denied when the pref is disabled on
// the client.
TEST_F(PasskeyTabHelperTest, AutomaticPasskeyUpgradePrefDisabled) {
+ passkey_tab_helper()->RecordPasswordLogin(
+ "", url::Origin::Create(GURL(kOriginURL)));
+
password_manager::PasswordForm form;
form.username_value = u"";
form.url = GURL(kOriginURL);
@@ -800,12 +905,77 @@
RegistrationRequestParams params = BuildRegistrationRequestParams({});
client_->SetAutomaticPasskeyUpgradeEnabled(false);
+ EXPECT_FALSE(
+ passkey_tab_helper()->HasAutomaticPasskeyUpgradeEligibility(params));
EXPECT_FALSE(CanPerformAutomaticPasskeyUpgrade(params, results));
client_->SetAutomaticPasskeyUpgradeEnabled(true);
+ EXPECT_TRUE(
+ passkey_tab_helper()->HasAutomaticPasskeyUpgradeEligibility(params));
EXPECT_TRUE(CanPerformAutomaticPasskeyUpgrade(params, results));
}
+// Tests that automatic passkey upgrade is allowed when only `date_last_filled`
+// is recent.
+TEST_F(PasskeyTabHelperTest, AutomaticPasskeyUpgradeDateLastFilled) {
+ passkey_tab_helper()->RecordPasswordLogin(
+ "", url::Origin::Create(GURL(kOriginURL)));
+
+ password_manager::PasswordForm form;
+ form.username_value = u"";
+ form.url = GURL(kOriginURL);
+ form.date_last_used = base::Time();
+ form.date_created = base::Time();
+ form.date_last_filled = base::Time::Now();
+
+ std::vector<password_manager::PasswordForm> results;
+ results.push_back(form);
+
+ RegistrationRequestParams params = BuildRegistrationRequestParams({});
+
+ EXPECT_TRUE(CanPerformAutomaticPasskeyUpgrade(params, results));
+}
+
+// Tests that automatic passkey upgrade is allowed when only `date_created` is
+// recent.
+TEST_F(PasskeyTabHelperTest, AutomaticPasskeyUpgradeDateCreated) {
+ passkey_tab_helper()->RecordPasswordLogin(
+ "", url::Origin::Create(GURL(kOriginURL)));
+
+ password_manager::PasswordForm form;
+ form.username_value = u"";
+ form.url = GURL(kOriginURL);
+ form.date_last_used = base::Time();
+ form.date_last_filled = base::Time::Now();
+ form.date_created = base::Time::Now();
+
+ std::vector<password_manager::PasswordForm> results;
+ results.push_back(form);
+
+ RegistrationRequestParams params = BuildRegistrationRequestParams({});
+
+ EXPECT_TRUE(CanPerformAutomaticPasskeyUpgrade(params, results));
+}
+
+// Tests that automatic passkey upgrade is denied if the credential has
+// `blocked_by_user` set to true.
+TEST_F(PasskeyTabHelperTest, AutomaticPasskeyUpgradeBlockedByUser) {
+ passkey_tab_helper()->RecordPasswordLogin(
+ "", url::Origin::Create(GURL(kOriginURL)));
+
+ password_manager::PasswordForm form;
+ form.username_value = u"";
+ form.url = GURL(kOriginURL);
+ form.date_last_used = base::Time::Now();
+ form.blocked_by_user = true;
+
+ std::vector<password_manager::PasswordForm> results;
+ results.push_back(form);
+
+ RegistrationRequestParams params = BuildRegistrationRequestParams({});
+
+ EXPECT_FALSE(CanPerformAutomaticPasskeyUpgrade(params, results));
+}
// Tests that a conditional create request does NOT show the incognito
// interstitial when automatic passkey upgrade is denied.
TEST_F(PasskeyTabHelperTest, ConditionalCreateOffTheRecordUpgradeDeny) {
@@ -842,6 +1012,8 @@
// Tests that a conditional create request does not fetch keys or show creation
// UI when automatic passkey upgrades are disabled in prefs.
TEST_F(PasskeyTabHelperTest, ConditionalCreateUpgradePrefDisabled) {
+ passkey_tab_helper()->RecordPasswordLogin(
+ "", url::Origin::Create(GURL(kOriginURL)));
client_->SetAutomaticPasskeyUpgradeEnabled(false);
SetUpWebFramesManagerAndWebFrame(GURL(kOriginURL));
@@ -880,6 +1052,8 @@
// when automatic passkey upgrade is allowed, and creation proceeds if the user
// chooses to proceed.
TEST_F(PasskeyTabHelperTest, ConditionalCreateOffTheRecordUpgradeAllowProceed) {
+ passkey_tab_helper()->RecordPasswordLogin(
+ "", url::Origin::Create(GURL(kOriginURL)));
fake_browser_state_.SetOffTheRecord(true);
SetUpWebFramesManagerAndWebFrame(GURL(kOriginURL));
SetUpIOSPasswordManagerDriver();
@@ -924,6 +1098,8 @@
// when automatic passkey upgrade is allowed, and creation is cancelled if the
// user cancels.
TEST_F(PasskeyTabHelperTest, ConditionalCreateOffTheRecordUpgradeAllowCancel) {
+ passkey_tab_helper()->RecordPasswordLogin(
+ "", url::Origin::Create(GURL(kOriginURL)));
fake_browser_state_.SetOffTheRecord(true);
SetUpWebFramesManagerAndWebFrame(GURL(kOriginURL));
SetUpIOSPasswordManagerDriver();
diff --git a/ios/chrome/browser/passwords/model/ios_chrome_password_manager_client_unittest.mm b/ios/chrome/browser/passwords/model/ios_chrome_password_manager_client_unittest.mm
index 8ceccf7..01f0f3d7 100644
--- a/ios/chrome/browser/passwords/model/ios_chrome_password_manager_client_unittest.mm
+++ b/ios/chrome/browser/passwords/model/ios_chrome_password_manager_client_unittest.mm
@@ -331,7 +331,7 @@
auto mock_form_manager =
... (truncated)
Original Bug Report
Potential gesture-free WebAuthn conditional passkey creation triggers GPM modal and FaceID prompts
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential logic bypass in Chrome for iOS allows a web page to trigger trusted browser-chrome UI or OS-level FaceID/TouchID biometric prompts without any user gesture. By initiating a conditional passkey creation request (kConditionalCreate) where user interaction checks are bypassed, an attacker could trigger highly-disruptive UI or social-engineering vectors. This issue is gated behind the experimental kIOSPasskeyConditionalLoginWithShim feature flag.
Affected files:
components/webauthn/ios/passkey_java_script_feature.mmcomponents/webauthn/ios/passkey_tab_helper.mm
Estimated timestamp from git blame: 2026-06-16
Summary
A potential vulnerability in Chrome for iOS allows an attacker-controlled web page to present highly-trusted Google Password Manager (GPM) modal screens or OS-level FaceID/TouchID prompts without any user gesture. This is due to a missing user interaction check in the passkey feature validation logic when handling conditional passkey creation (kConditionalCreate) requests.
Affected Code
- File:
components/webauthn/ios/passkey_java_script_feature.mm - File:
components/webauthn/ios/passkey_tab_helper.mm
Vulnerability Mechanism and Root Cause
In components/webauthn/ios/passkey_java_script_feature.mm, the ValidateFeatureUsage function gates the is_user_interacting() check only on kModal requests. Requests of type kConditionalCreate fall through to the else block, where the user interaction status is completely ignored:
bool ValidateFeatureUsage(const PasskeyRequestParams& request_params,
bool has_user_interaction) {
if (request_params.Type() == PasskeyRequestParams::RequestType::kModal) {
if (!has_user_interaction) {
return false;
}
return base::FeatureList::IsEnabled(kIOSPasskeyModalLoginWithShim);
} else {
// Missing has_user_interaction check here!
return base::FeatureList::IsEnabled(kIOSPasskeyConditionalLoginWithShim);
}
}
Because the WebAuthn injection script runs across all frames (FeatureScript::TargetFrames::kAllFrames) inside the page content world, any untrusted subframe or page script can issue a conditional WebAuthn creation request (e.g., via navigator.credentials.create with mediation: 'conditional') without a user gesture.
Potential Step-by-Step Attack Scenario
Note: Our analysis is static and our tooling does not currently have the capability to execute dynamic proof-of-concept code. These steps are based on a rigorous code-flow trace.
- The victim visits an attacker-controlled page (e.g.,
https://attacker.example). The victim must have previously saved a credential for this domain inside GPM, and used/autofilled it within the last 5 minutes. - The page loads and executes a conditional passkey creation request (setting
isConditional: trueanduserVerification: 'required') inside a script (e.g.,onloadevent) with no user gesture. - The Chrome iOS JS shim intercepts the request and sends an IPC message to the browser process via
webkit.messageHandlers.PasskeyInteractionHandler. - In the browser process,
ScriptMessageReceivedis invoked. Because of the missinghas_user_interactioncheck inValidateFeatureUsage, the validation passes. - The request is passed to
PasskeyTabHelper::MaybeShowInterstitialAndRegister. Since the request is a conditional create request, it bypasses the Incognito interstitial check and goes directly toHandleRegistration. HandleRegistrationqueries the password store. The callbackOnGetPasswordStoreResultsOrErrorFrommatches the recently used credential viaCanPerformAutomaticPasskeyUpgrade(verifying it was used within 5 minutes).- If the upgrade check succeeds in a non-Incognito session, Chrome proceeds to auto-upgrade and calls
StartPasskeyCreation. StartPasskeyCreationinvokesclient_->FetchKeys.- Depending on the GPM account state, this invokes the Keychain provider bridge:
- Sink 1 (Not enrolled / Degraded / Reauth needed): Starts
PasskeyWelcomeScreenCoordinator, presenting a Google-branded GPM modal screen displaying the user’s primary email address with no user gesture. - Sink 2 (Account enrolled & healthy): Because
userVerification: 'required'was specified, it attempts re-authentication via the local re-auth module, immediately triggering the OS-level FaceID/TouchID biometric prompt on top of the browser with no user gesture.
- Sink 1 (Not enrolled / Degraded / Reauth needed): Starts
Suggested Fix
In components/webauthn/ios/passkey_java_script_feature.mm:ValidateFeatureUsage, ensure that conditional passkey creation also enforces user interaction constraints, or handle it appropriately during the automatic upgrade trigger to prevent automatic background upgrades from spawning modal coordinators or biometric prompts without prior active user gesture on the document.
Evaluated with Chrome root at commit: 84065d9121f6e48f67755f0ae963cc09617e5c85
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.