diff --git a/ios/chrome/browser/credential_provider/model/credential_provider_migrator_unittest.mm b/ios/chrome/browser/credential_provider/model/credential_provider_migrator_unittest.mm index 0ced9ed9a6690..8e6e648371367 100644 --- a/ios/chrome/browser/credential_provider/model/credential_provider_migrator_unittest.mm +++ b/ios/chrome/browser/credential_provider/model/credential_provider_migrator_unittest.mm @@ -39,6 +39,22 @@ NSData* StringToData(std::string str) { return [NSData dataWithBytes:str.data() length:str.length()]; } +// Creates a password credential tagged with a specific gaia ID — simulating +// what the CPE writes into the shared App-Group defaults. +ArchivableCredential* TestPasswordCredentialForGaia(NSString* gaia) { + return [[ArchivableCredential alloc] + initWithFavicon:nil + gaia:gaia + password:@"qwerty123" + rank:1 + recordIdentifier:@"recordIdentifier" + serviceIdentifier:@"http://www.alpha.example.com/path/and?args=8" + serviceName:nil + registryControlledDomain:nil + username:@"username_value" + note:@"note"]; +} + ArchivableCredential* TestPasswordCredential() { NSString* username = @"username_value"; NSString* password = @"qwerty123"; @@ -141,6 +157,89 @@ TEST_F(CredentialProviderMigratorTest, Migration) { EXPECT_EQ(store.credentials.count, 0u); } +// POC: cross-profile credential leak. +// +// CredentialProviderMigratorAppAgent::-migrateCredentialForAllPasskeyModels +// loops GetLoadedProfiles() synchronously on the main thread and creates one +// CredentialProviderMigrator per profile, all reading the SAME profile-agnostic +// App-Group key. Each migrator instantiates its own UserDefaultsCredentialStore +// with its own dispatch_queue; -startMigrationWithCompletion: synchronously +// loads the blob and calls AddLogin() / CreatePasskey() into THAT profile's +// store; the clearing write (-saveDataWithCompletion:) is +// dispatch_barrier_async on the per-instance queue, so the next iteration of +// the loop re-reads the still-populated NSUserDefaults entry. +// +// Additionally, neither PasswordFormFromCredential nor PasskeyFromCredential +// consult `credential.gaia`, so even the FIRST iteration consumes credentials +// gaia-tagged for a different profile. +// +// This test mirrors the app agent's loop with two "profiles" (two distinct +// password stores) and a credential explicitly gaia-tagged for the personal +// account. It asserts the SECURE behaviour (managed store NEVER receives the +// credential). The test FAILS — which is the proof of the leak. +TEST_F(CredentialProviderMigratorTest, POC_CrossProfileLeak) { + // 1) The CPE writes a NEW password into the shared App-Group key, tagged + // with the personal account's gaia ID. + UserDefaultsCredentialStore* cpeStore = + [[UserDefaultsCredentialStore alloc] initWithUserDefaults:user_defaults_ + key:store_key_]; + id credential = + TestPasswordCredentialForGaia(@"personal-gaia-id"); + [cpeStore addCredential:credential]; + __block BOOL saved = NO; + [cpeStore saveDataWithCompletion:^(NSError* error) { + saved = YES; + }]; + ASSERT_TRUE(WaitUntilConditionOrTimeout(kWaitForFileOperationTimeout, ^bool { + return saved; + })); + ASSERT_EQ(cpeStore.credentials.count, 1u); + + // 2) Two loaded profiles → two destination password stores. + scoped_refptr personal_store = + base::MakeRefCounted>(); + scoped_refptr managed_store = + base::MakeRefCounted>(); + + PasswordForm expected = PasswordFormFromCredential(credential); + + // Personal store SHOULD receive it (gaia matches). + EXPECT_CALL(*personal_store, AddLogin(expected, _)).Times(1); + + // Managed store should NEVER receive a credential gaia-tagged for the + // personal account. + // + // *** THIS EXPECTATION FAILS *** + // The migrator ignores `credential.gaia` and the per-profile loop re-reads + // the unflushed App-Group blob, so the personal cleartext password is + // imported into the managed profile's password store as well. + EXPECT_CALL(*managed_store, AddLogin(_, _)).Times(0); + + // 3) Mirror -migrateCredentialForAllPasskeyModels: synchronous main-thread + // loop, one fresh migrator per profile, same key/userDefaults, no wait + // for the previous migrator's async flush. + scoped_refptr stores[] = {personal_store, + managed_store}; + __block int completions = 0; + for (auto& store : stores) { + CredentialProviderMigrator* migrator = + [[CredentialProviderMigrator alloc] initWithUserDefaults:user_defaults_ + key:store_key_ + passwordStore:store + passkeyStore:nil]; + // -startMigrationWithCompletion: synchronously loads NSUserDefaults + // (dispatch_sync on a fresh per-instance queue) and calls AddLogin() + // before returning. The clearing save is dispatch_barrier_async. + [migrator startMigrationWithCompletion:^(BOOL success, NSError* error) { + completions++; + }]; + } + + EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForFileOperationTimeout, ^bool { + return completions == 2; + })); +} + // Tests basic migration for 1 passkey credential. TEST_F(CredentialProviderMigratorTest, PasskeyMigration) { // Create temp store and add 1 credential.